mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding asset manager and other new features
This commit is contained in:
@@ -31,6 +31,7 @@ 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
|
||||||
@@ -63,6 +64,7 @@ export interface ServerToClientEvents {
|
|||||||
"manager:playerKicked": (_playerId: string) => void
|
"manager:playerKicked": (_playerId: string) => void
|
||||||
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||||
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||||
|
"manager:quizzDeleted": (_id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
@@ -73,8 +75,11 @@ 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:skipQuestionIntro": (_message: MessageGameId) => void
|
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
||||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||||
|
"manager:deleteQuizz": (_message: { id: string }) => void
|
||||||
"manager:showLeaderboard": (_message: MessageGameId) => void
|
"manager:showLeaderboard": (_message: MessageGameId) => void
|
||||||
"manager:getQuizz": (_quizzId: string) => void
|
"manager:getQuizz": (_quizzId: string) => void
|
||||||
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
||||||
|
|||||||
@@ -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) => {
|
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,6 +188,14 @@ 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:nextQuestion", ({ gameId }) =>
|
socket.on("manager:nextQuestion", ({ gameId }) =>
|
||||||
withGame(gameId, socket, (game) => game.nextRound(socket))
|
withGame(gameId, socket, (game) => game.nextRound(socket))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,6 +214,18 @@ 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 = "") {
|
static getMediaPath(fileName: string = "") {
|
||||||
this.ensureBaseFolders()
|
this.ensureBaseFolders()
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ class Game {
|
|||||||
|
|
||||||
cooldown: {
|
cooldown: {
|
||||||
active: boolean
|
active: boolean
|
||||||
ms: number
|
paused: boolean
|
||||||
|
remaining: number
|
||||||
|
timer: NodeJS.Timeout | null
|
||||||
|
resolve: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
||||||
@@ -75,7 +78,10 @@ class Game {
|
|||||||
|
|
||||||
this.cooldown = {
|
this.cooldown = {
|
||||||
active: false,
|
active: false,
|
||||||
ms: 0,
|
paused: false,
|
||||||
|
remaining: 0,
|
||||||
|
timer: null,
|
||||||
|
resolve: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomInvite = createInviteCode()
|
const roomInvite = createInviteCode()
|
||||||
@@ -271,26 +277,80 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial emit
|
||||||
|
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||||
|
|
||||||
|
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.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) {
|
skipQuestionIntro(socket: Socket) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
||||||
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"
|
||||||
@@ -18,6 +19,7 @@ const Manager = () => {
|
|||||||
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)
|
||||||
|
|
||||||
useEvent("manager:quizzList", (quizzList) => {
|
useEvent("manager:quizzList", (quizzList) => {
|
||||||
setIsAuth(true)
|
setIsAuth(true)
|
||||||
@@ -51,11 +53,28 @@ const Manager = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMedia) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMedia(false)}
|
||||||
|
className="rounded-md bg-gray-700 px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MediaLibrary />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectQuizz
|
<SelectQuizz
|
||||||
quizzList={quizzList}
|
quizzList={quizzList}
|
||||||
onSelect={handleCreate}
|
onSelect={handleCreate}
|
||||||
onManage={() => setShowEditor(true)}
|
onManage={() => setShowEditor(true)}
|
||||||
|
onMedia={() => setShowMedia(true)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@ 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)
|
||||||
|
|
||||||
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 +56,10 @@ const ManagerGame = () => {
|
|||||||
toast.error(message)
|
toast.error(message)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent("game:cooldownPause", (isPaused) => {
|
||||||
|
setCooldownPaused(isPaused)
|
||||||
|
})
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
if (!gameId) {
|
if (!gameId) {
|
||||||
return
|
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
|
let component = null
|
||||||
|
|
||||||
switch (status?.name) {
|
switch (status?.name) {
|
||||||
@@ -132,7 +147,16 @@ 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
|
||||||
|
}
|
||||||
|
manager
|
||||||
|
>
|
||||||
{component}
|
{component}
|
||||||
</GameWrapper>
|
</GameWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,10 +15,21 @@ 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
|
||||||
manager?: boolean
|
manager?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
const GameWrapper = ({
|
||||||
|
children,
|
||||||
|
statusName,
|
||||||
|
onNext,
|
||||||
|
onPause,
|
||||||
|
paused,
|
||||||
|
showPause,
|
||||||
|
manager,
|
||||||
|
}: Props) => {
|
||||||
const { isConnected } = useSocket()
|
const { isConnected } = useSocket()
|
||||||
const { player } = usePlayerStore()
|
const { player } = usePlayerStore()
|
||||||
const { questionStates, setQuestionStates } = useQuestionStore()
|
const { questionStates, setQuestionStates } = useQuestionStore()
|
||||||
@@ -75,6 +86,17 @@ 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
|
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
media?: QuestionMediaType
|
media?: QuestionMediaType
|
||||||
@@ -10,6 +11,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||||
|
const [zoomed, setZoomed] = useState(false)
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -19,13 +22,28 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
|||||||
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":
|
||||||
|
|||||||
147
packages/web/src/components/game/create/MediaLibrary.tsx
Normal file
147
packages/web/src/components/game/create/MediaLibrary.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Button from "@rahoot/web/components/Button"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
type MediaItem = {
|
||||||
|
fileName: string
|
||||||
|
url: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
type: string
|
||||||
|
usedBy: {
|
||||||
|
quizzId: string
|
||||||
|
subject: string
|
||||||
|
questionIndex: number
|
||||||
|
question: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (!bytes) return "0 B"
|
||||||
|
const units = ["B", "KB", "MB", "GB"]
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
const value = bytes / 1024 ** i
|
||||||
|
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaLibrary = () => {
|
||||||
|
const [items, setItems] = useState<MediaItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/media", { cache: "no-store" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to load media")
|
||||||
|
setItems(data.media || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = async (fileName: string) => {
|
||||||
|
setDeleting((prev) => ({ ...prev, [fileName]: true }))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to delete file")
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
alert(error instanceof Error ? error.message : "Failed to delete")
|
||||||
|
} finally {
|
||||||
|
setDeleting((prev) => ({ ...prev, [fileName]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">Media library</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Uploaded files with their usage. Delete is enabled only when unused.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-gray-700" onClick={load} disabled={loading}>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-xs uppercase text-gray-500">
|
||||||
|
<th className="p-2">File</th>
|
||||||
|
<th className="p-2">Type</th>
|
||||||
|
<th className="p-2">Size</th>
|
||||||
|
<th className="p-2">Used by</th>
|
||||||
|
<th className="p-2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.fileName} className="border-b border-gray-100">
|
||||||
|
<td className="p-2 font-semibold text-gray-800">
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
>
|
||||||
|
{item.fileName}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{item.type}</td>
|
||||||
|
<td className="p-2 text-gray-600">{formatBytes(item.size)}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{item.usedBy.length === 0 ? (
|
||||||
|
<span className="text-green-700">Unused</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.usedBy.map((u, idx) => (
|
||||||
|
<div key={idx} className="text-gray-700">
|
||||||
|
<span className="font-semibold">{u.subject || u.quizzId}</span>
|
||||||
|
{` – Q${u.questionIndex + 1}: ${u.question}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<Button
|
||||||
|
className="bg-red-500 px-3 py-1 text-sm"
|
||||||
|
onClick={() => handleDelete(item.fileName)}
|
||||||
|
disabled={item.usedBy.length > 0 || deleting[item.fileName]}
|
||||||
|
>
|
||||||
|
{deleting[item.fileName] ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td className="p-3 text-sm text-gray-500" colSpan={5}>
|
||||||
|
No media uploaded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaLibrary
|
||||||
@@ -80,6 +80,15 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
refreshMediaLibrary()
|
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) => {
|
||||||
onListUpdate(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 = (
|
const updateQuestion = (
|
||||||
index: number,
|
index: number,
|
||||||
patch: Partial<EditableQuestion>,
|
patch: Partial<EditableQuestion>,
|
||||||
@@ -445,15 +461,20 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<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 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">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleNew} className="bg-blue-600">
|
<Button onClick={handleNew} className="bg-blue-600">
|
||||||
New quiz
|
New quiz
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{selectedId && (
|
||||||
|
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
||||||
|
Delete quiz
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={saving || loading}>
|
<Button onClick={handleSave} disabled={saving || loading}>
|
||||||
{saving ? "Saving..." : "Save quiz"}
|
{saving ? "Saving..." : "Save quiz"}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ type Props = {
|
|||||||
quizzList: QuizzWithId[]
|
quizzList: QuizzWithId[]
|
||||||
onSelect: (_id: string) => void
|
onSelect: (_id: string) => void
|
||||||
onManage?: () => void
|
onManage?: () => void
|
||||||
|
onMedia?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSelect = (id: string) => () => {
|
const handleSelect = (id: string) => () => {
|
||||||
@@ -35,14 +36,24 @@ const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
|||||||
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="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>
|
||||||
{onManage && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{onMedia && (
|
||||||
className="text-sm font-semibold text-primary underline"
|
<button
|
||||||
onClick={onManage}
|
className="text-sm font-semibold text-gray-700 underline"
|
||||||
>
|
onClick={onMedia}
|
||||||
Manage
|
>
|
||||||
</button>
|
Media
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{onManage && (
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-primary underline"
|
||||||
|
onClick={onManage}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -78,6 +78,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 +106,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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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 { 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 +12,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 +41,17 @@ const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
|||||||
alt={question}
|
alt={question}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mb-20 h-4 w-full max-w-4xl self-start overflow-hidden rounded-full bg-white/30">
|
||||||
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
|
<div
|
||||||
style={{ animation: `progressBar ${cooldown}s linear forwards` }}
|
className="h-full bg-primary transition-[width]"
|
||||||
></div>
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{paused && (
|
||||||
|
<div className="absolute bottom-6 right-6 rounded-md bg-black/60 px-3 py-1 text-sm font-semibold text-white">
|
||||||
|
Paused
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user