diff --git a/packages/common/src/types/game/socket.ts b/packages/common/src/types/game/socket.ts index 26d94e7..d7c4fcc 100644 --- a/packages/common/src/types/game/socket.ts +++ b/packages/common/src/types/game/socket.ts @@ -31,6 +31,7 @@ export interface ServerToClientEvents { "game:errorMessage": (_message: string) => void "game:startCooldown": () => void "game:cooldown": (_count: number) => void + "game:cooldownPause": (_paused: boolean) => void "game:reset": (_message: string) => void "game:updateQuestion": (_data: { current: number; total: number }) => void "game:playerAnswer": (_count: number) => void @@ -63,6 +64,7 @@ export interface ServerToClientEvents { "manager:playerKicked": (_playerId: string) => void "manager:quizzLoaded": (_quizz: QuizzWithId) => void "manager:quizzSaved": (_quizz: QuizzWithId) => void + "manager:quizzDeleted": (_id: string) => void } export interface ClientToServerEvents { @@ -73,8 +75,11 @@ export interface ClientToServerEvents { "manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void "manager:startGame": (_message: MessageGameId) => void "manager:abortQuiz": (_message: MessageGameId) => void + "manager:pauseCooldown": (_message: MessageGameId) => void + "manager:resumeCooldown": (_message: MessageGameId) => void "manager:skipQuestionIntro": (_message: MessageGameId) => void "manager:nextQuestion": (_message: MessageGameId) => void + "manager:deleteQuizz": (_message: { id: string }) => void "manager:showLeaderboard": (_message: MessageGameId) => void "manager:getQuizz": (_quizzId: string) => void "manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index 74dc9bc..a25525f 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -111,6 +111,27 @@ io.on("connection", (socket) => { } }) + socket.on("manager:deleteQuizz", ({ id }) => { + if (!id) { + socket.emit("manager:errorMessage", "Invalid quizz id") + return + } + + try { + const deleted = Config.deleteQuizz(id) + if (!deleted) { + socket.emit("manager:errorMessage", "Quizz not found") + return + } + + socket.emit("manager:quizzDeleted", id) + socket.emit("manager:quizzList", Config.quizz()) + } catch (error) { + console.error("Failed to delete quizz", error) + socket.emit("manager:errorMessage", "Failed to delete quizz") + } + }) + socket.on("game:create", (quizzId) => { const quizzList = Config.quizz() const quizz = quizzList.find((q) => q.id === quizzId) @@ -167,6 +188,14 @@ io.on("connection", (socket) => { withGame(gameId, socket, (game) => game.abortRound(socket)) ) + socket.on("manager:pauseCooldown", ({ gameId }) => + withGame(gameId, socket, (game) => game.pauseCooldown(socket)) + ) + + socket.on("manager:resumeCooldown", ({ gameId }) => + withGame(gameId, socket, (game) => game.resumeCooldown(socket)) + ) + socket.on("manager:nextQuestion", ({ gameId }) => withGame(gameId, socket, (game) => game.nextRound(socket)) ) diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts index cc25d97..af66a88 100644 --- a/packages/socket/src/services/config.ts +++ b/packages/socket/src/services/config.ts @@ -214,6 +214,18 @@ class Config { return this.getQuizz(finalId) } + static deleteQuizz(id: string) { + this.ensureBaseFolders() + const filePath = getPath(`quizz/${id}.json`) + + if (!fs.existsSync(filePath)) { + return false + } + + fs.unlinkSync(filePath) + return true + } + static getMediaPath(fileName: string = "") { this.ensureBaseFolders() diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index 0e7bf40..4a7e34d 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -40,7 +40,10 @@ class Game { cooldown: { active: boolean - ms: number + paused: boolean + remaining: number + timer: NodeJS.Timeout | null + resolve: (() => void) | null } constructor(io: Server, socket: Socket, quizz: Quizz) { @@ -75,7 +78,10 @@ class Game { this.cooldown = { active: false, - ms: 0, + paused: false, + remaining: 0, + timer: null, + resolve: null, } const roomInvite = createInviteCode() @@ -271,26 +277,80 @@ class Game { } this.cooldown.active = true - let count = seconds - 1 + this.cooldown.paused = false + this.cooldown.remaining = seconds return new Promise((resolve) => { - const cooldownTimeout = setInterval(() => { - if (!this.cooldown.active || count <= 0) { - this.cooldown.active = false - clearInterval(cooldownTimeout) - resolve() + this.cooldown.resolve = resolve + const tick = () => { + if (!this.cooldown.active) { + this.finishCooldown() return } - this.io.to(this.gameId).emit("game:cooldown", count) - count -= 1 - }, 1000) + if (this.cooldown.paused) { + return + } + + this.cooldown.remaining -= 1 + + if (this.cooldown.remaining <= 0) { + this.finishCooldown() + return + } + + this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining) + } + + // initial emit + this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining) + + this.cooldown.timer = setInterval(tick, 1000) }) } abortCooldown() { - this.cooldown.active &&= false + if (!this.cooldown.active) { + return + } + + this.cooldown.active = false + this.cooldown.paused = false + this.io.to(this.gameId).emit("game:cooldownPause", false) + this.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) + } + + 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) } skipQuestionIntro(socket: Socket) { diff --git a/packages/web/src/app/(auth)/manager/page.tsx b/packages/web/src/app/(auth)/manager/page.tsx index 1229377..7394158 100644 --- a/packages/web/src/app/(auth)/manager/page.tsx +++ b/packages/web/src/app/(auth)/manager/page.tsx @@ -4,6 +4,7 @@ import { QuizzWithId } from "@rahoot/common/types/game" import { STATUS } from "@rahoot/common/types/game/status" import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword" import QuizEditor from "@rahoot/web/components/game/create/QuizEditor" +import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary" import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useManagerStore } from "@rahoot/web/stores/manager" @@ -18,6 +19,7 @@ const Manager = () => { const [isAuth, setIsAuth] = useState(false) const [quizzList, setQuizzList] = useState([]) const [showEditor, setShowEditor] = useState(false) + const [showMedia, setShowMedia] = useState(false) useEvent("manager:quizzList", (quizzList) => { setIsAuth(true) @@ -51,11 +53,28 @@ const Manager = () => { ) } + if (showMedia) { + return ( +
+
+ +
+ +
+ ) + } + return ( setShowEditor(true)} + onMedia={() => setShowMedia(true)} /> ) } diff --git a/packages/web/src/app/game/manager/[gameId]/page.tsx b/packages/web/src/app/game/manager/[gameId]/page.tsx index d4a1bde..71a0e7a 100644 --- a/packages/web/src/app/game/manager/[gameId]/page.tsx +++ b/packages/web/src/app/game/manager/[gameId]/page.tsx @@ -16,6 +16,7 @@ import { useQuestionStore } from "@rahoot/web/stores/question" import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants" import { useParams, useRouter } from "next/navigation" import toast from "react-hot-toast" +import { useState } from "react" const ManagerGame = () => { const router = useRouter() @@ -24,6 +25,7 @@ const ManagerGame = () => { const { gameId, status, setGameId, setStatus, setPlayers, reset } = useManagerStore() const { setQuestionStates } = useQuestionStore() + const [cooldownPaused, setCooldownPaused] = useState(false) useEvent("game:status", ({ name, data }) => { if (name in GAME_STATE_COMPONENTS_MANAGER) { @@ -54,6 +56,10 @@ const ManagerGame = () => { toast.error(message) }) + useEvent("game:cooldownPause", (isPaused) => { + setCooldownPaused(isPaused) + }) + const handleSkip = () => { if (!gameId) { return @@ -87,6 +93,15 @@ const ManagerGame = () => { } } + const handlePauseToggle = () => { + if (!gameId) return + if (cooldownPaused) { + socket?.emit("manager:resumeCooldown", { gameId }) + } else { + socket?.emit("manager:pauseCooldown", { gameId }) + } + } + let component = null switch (status?.name) { @@ -132,7 +147,16 @@ const ManagerGame = () => { } return ( - + {component} ) diff --git a/packages/web/src/components/game/GameWrapper.tsx b/packages/web/src/components/game/GameWrapper.tsx index b6864b0..e51250d 100644 --- a/packages/web/src/components/game/GameWrapper.tsx +++ b/packages/web/src/components/game/GameWrapper.tsx @@ -15,10 +15,21 @@ import { PropsWithChildren, useEffect, useState } from "react" type Props = PropsWithChildren & { statusName: Status | undefined onNext?: () => void + onPause?: () => void + paused?: boolean + showPause?: boolean manager?: boolean } -const GameWrapper = ({ children, statusName, onNext, manager }: Props) => { +const GameWrapper = ({ + children, + statusName, + onNext, + onPause, + paused, + showPause, + manager, +}: Props) => { const { isConnected } = useSocket() const { player } = usePlayerStore() const { questionStates, setQuestionStates } = useQuestionStore() @@ -75,6 +86,17 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => { {next} )} + + {manager && showPause && ( + + )} {children} diff --git a/packages/web/src/components/game/QuestionMedia.tsx b/packages/web/src/components/game/QuestionMedia.tsx index 9495b17..e2b98a6 100644 --- a/packages/web/src/components/game/QuestionMedia.tsx +++ b/packages/web/src/components/game/QuestionMedia.tsx @@ -2,6 +2,7 @@ import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game" import clsx from "clsx" +import { useState } from "react" type Props = { media?: QuestionMediaType @@ -10,6 +11,8 @@ type Props = { } const QuestionMedia = ({ media, alt, onPlayChange }: Props) => { + const [zoomed, setZoomed] = useState(false) + if (!media) { return null } @@ -19,13 +22,28 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => { switch (media.type) { case "image": return ( -
+ <> +
{alt} setZoomed(true)} />
+ {zoomed && ( +
setZoomed(false)} + > + {alt} +
+ )} + ) case "audio": diff --git a/packages/web/src/components/game/create/MediaLibrary.tsx b/packages/web/src/components/game/create/MediaLibrary.tsx new file mode 100644 index 0000000..a79b4b2 --- /dev/null +++ b/packages/web/src/components/game/create/MediaLibrary.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [deleting, setDeleting] = useState>({}) + + 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 ( +
+
+
+

Media library

+

+ Uploaded files with their usage. Delete is enabled only when unused. +

+
+ +
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + {items.length === 0 && !loading && ( + + + + )} + +
FileTypeSizeUsed byActions
+ + {item.fileName} + + {item.type}{formatBytes(item.size)} + {item.usedBy.length === 0 ? ( + Unused + ) : ( +
+ {item.usedBy.map((u, idx) => ( +
+ {u.subject || u.quizzId} + {` – Q${u.questionIndex + 1}: ${u.question}`} +
+ ))} +
+ )} +
+ +
+ No media uploaded yet. +
+
+
+ ) +} + +export default MediaLibrary diff --git a/packages/web/src/components/game/create/QuizEditor.tsx b/packages/web/src/components/game/create/QuizEditor.tsx index d377fb6..99f0bd6 100644 --- a/packages/web/src/components/game/create/QuizEditor.tsx +++ b/packages/web/src/components/game/create/QuizEditor.tsx @@ -80,6 +80,15 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { refreshMediaLibrary() }) + useEvent("manager:quizzDeleted", (id) => { + toast.success("Quiz deleted") + if (selectedId === id) { + setSelectedId(null) + setDraft(null) + } + refreshMediaLibrary() + }) + useEvent("manager:quizzList", (list) => { onListUpdate(list) }) @@ -130,6 +139,13 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { }) } + const handleDeleteQuizz = () => { + if (!selectedId) return + if (!window.confirm("Delete this quiz?")) return + setSaving(true) + socket?.emit("manager:deleteQuizz", { id: selectedId }) + } + const updateQuestion = ( index: number, patch: Partial, @@ -445,15 +461,20 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { return (
-
-
- - -
+
+
+ + + {selectedId && ( + + )} +
- )} +
+ {onMedia && ( + + )} + {onManage && ( + + )} +
diff --git a/packages/web/src/components/game/states/Answers.tsx b/packages/web/src/components/game/states/Answers.tsx index 77bc422..ae87eaf 100644 --- a/packages/web/src/components/game/states/Answers.tsx +++ b/packages/web/src/components/game/states/Answers.tsx @@ -78,6 +78,10 @@ const Answers = ({ setCooldown(sec) }) + useEvent("game:cooldownPause", (isPaused) => { + setPaused(isPaused) + }) + useEvent("game:playerAnswer", (count) => { setTotalAnswer(count) sfxPop() @@ -102,6 +106,11 @@ const Answers = ({
Time {cooldown} + {paused && ( + + Paused + + )}
Answers diff --git a/packages/web/src/components/game/states/Question.tsx b/packages/web/src/components/game/states/Question.tsx index 07e6f64..80ccc66 100644 --- a/packages/web/src/components/game/states/Question.tsx +++ b/packages/web/src/components/game/states/Question.tsx @@ -3,7 +3,7 @@ import { CommonStatusDataMap } from "@rahoot/common/types/game/status" import QuestionMedia from "@rahoot/web/components/game/QuestionMedia" import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants" -import { useEffect } from "react" +import { useEffect, useState } from "react" import useSound from "use-sound" type Props = { @@ -12,11 +12,23 @@ type Props = { const Question = ({ data: { question, image, media, cooldown } }: Props) => { const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 }) + const [seconds, setSeconds] = useState(cooldown) + const [paused, setPaused] = useState(false) useEffect(() => { sfxShow() }, [sfxShow]) + useEvent("game:cooldown", (sec) => { + setSeconds(sec) + }) + + useEvent("game:cooldownPause", (isPaused) => { + setPaused(isPaused) + }) + + const percent = Math.max(0, Math.min(100, (seconds / cooldown) * 100)) + return (
@@ -29,10 +41,17 @@ const Question = ({ data: { question, image, media, cooldown } }: Props) => { alt={question} />
-
+
+
+
+ {paused && ( +
+ Paused +
+ )}
) }