adding manager UI and audio and video (youtube) questions

This commit is contained in:
RandyJC
2025-11-28 21:17:18 +01:00
parent c79d82c565
commit 14ea9c75cd
16 changed files with 1018 additions and 80 deletions

View File

@@ -134,6 +134,7 @@ Example quiz configuration (`config/quizz/example.json`):
"question": "What is the correct answer?",
"answers": ["No", "Yes", "No", "No"],
"image": "https://images.unsplash.com/....",
"media": { "type": "audio", "url": "https://example.com/song.mp3" },
"solution": 1,
"cooldown": 5,
"time": 15
@@ -148,11 +149,17 @@ Quiz Options:
- `questions`: Array of question objects containing:
- `question`: The question text
- `answers`: Array of possible answers (2-4 options)
- `image`: Optional URL for question image
- `image`: Optional URL for question image (legacy; use `media` for new content)
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video" | "youtube", "url": "<link>" }`. Examples:
- `{"type":"audio","url":"https://.../clip.mp3"}`
- `{"type":"video","url":"https://.../clip.mp4"}`
- `{"type":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}`
- `solution`: Index of correct answer (0-based)
- `cooldown`: Time in seconds before showing the question
- `time`: Time in seconds allowed to answer
Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”).
## 🎮 How to Play
1. Access the manager interface at http://localhost:3000/manager

View File

@@ -2,27 +2,52 @@
"subject": "Example Quizz",
"questions": [
{
"question": "What is good answer ?",
"answers": ["No", "Good answer", "No", "No"],
"question": "Which soundtrack is this?",
"answers": [
"Nature sounds",
"Piano solo",
"Electronic beat",
"Chill guitar"
],
"media": {
"type": "audio",
"url": "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3"
},
"solution": 1,
"cooldown": 5,
"time": 15
"time": 25
},
{
"question": "What is good answer with image ?",
"answers": ["No", "No", "No", "Good answer"],
"image": "https://placehold.co/600x400.png",
"question": "Which landmark appears in this clip?",
"answers": [
"Eiffel Tower",
"Sydney Opera House",
"Statue of Liberty",
"Golden Gate Bridge"
],
"media": {
"type": "youtube",
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw"
},
"solution": 3,
"cooldown": 5,
"time": 20
"time": 60
},
{
"question": "What is good answer with two answers ?",
"answers": ["Good answer", "No"],
"image": "https://placehold.co/600x400.png",
"solution": 0,
"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": 20
"time": 40
}
]
}

View File

@@ -17,6 +17,7 @@ export type Quizz = {
questions: {
question: string
image?: string
media?: QuestionMedia
answers: string[]
solution: number
cooldown: number
@@ -24,6 +25,12 @@ export type Quizz = {
}[]
}
export type QuestionMedia =
| { type: "image"; url: string }
| { type: "audio"; url: string }
| { type: "video"; url: string }
| { type: "youtube"; url: string }
export type QuizzWithId = Quizz & { id: string }
export type GameUpdateQuestion = {

View File

@@ -1,4 +1,4 @@
import { Player } from "."
import { Player, QuestionMedia } from "."
export const STATUS = {
SHOW_ROOM: "SHOW_ROOM",
@@ -18,11 +18,17 @@ export type Status = (typeof STATUS)[keyof typeof STATUS]
export type CommonStatusDataMap = {
SHOW_START: { time: number; subject: string }
SHOW_PREPARED: { totalAnswers: number; questionNumber: number }
SHOW_QUESTION: { question: string; image?: string; cooldown: number }
SHOW_QUESTION: {
question: string
image?: string
media?: QuestionMedia
cooldown: number
}
SELECT_ANSWER: {
question: string
answers: string[]
image?: string
media?: QuestionMedia
time: number
totalPlayer: number
}
@@ -46,6 +52,7 @@ type ManagerExtraStatus = {
correct: number
answers: string[]
image?: string
media?: QuestionMedia
}
SHOW_LEADERBOARD: { oldLeaderboard: Player[]; leaderboard: Player[] }
}

View File

@@ -7,9 +7,18 @@ import Registry from "@rahoot/socket/services/registry"
import { withGame } from "@rahoot/socket/utils/game"
import { Server as ServerIO } from "socket.io"
const corsOrigins =
process.env.NODE_ENV !== "production"
? "*"
: env.WEB_ORIGIN === "*"
? "*"
: [env.WEB_ORIGIN, "http://localhost:3000", "http://127.0.0.1:3000"]
const io: Server = new ServerIO({
cors: {
origin: [env.WEB_ORIGIN],
origin: corsOrigins,
methods: ["GET", "POST"],
credentials: false,
},
})
Config.init()
@@ -66,6 +75,42 @@ io.on("connection", (socket) => {
}
})
socket.on("manager:getQuizz", (quizzId) => {
const quizz = Config.getQuizz(quizzId)
if (!quizz) {
socket.emit("manager:errorMessage", "Quizz not found")
return
}
socket.emit("manager:quizzLoaded", quizz)
})
socket.on("manager:saveQuizz", ({ id, quizz }) => {
if (!quizz?.subject || !Array.isArray(quizz?.questions)) {
socket.emit("manager:errorMessage", "Invalid quizz payload")
return
}
try {
const saved = Config.saveQuizz(id || null, quizz)
if (!saved) {
socket.emit("manager:errorMessage", "Failed to save quizz")
return
}
socket.emit("manager:quizzSaved", saved)
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to save quizz", error)
socket.emit("manager:errorMessage", "Failed to save quizz")
}
})
socket.on("game:create", (quizzId) => {
const quizzList = Config.quizz()
const quizz = quizzList.find((q) => q.id === quizzId)

View File

@@ -2,6 +2,13 @@ import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
const inContainerPath = process.env.CONFIG_PATH
const getPath = (path: string = "") =>
@@ -10,13 +17,23 @@ const getPath = (path: string = "") =>
: resolve(process.cwd(), "../../config", path)
class Config {
static init() {
static ensureBaseFolders() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
}
}
static init() {
this.ensureBaseFolders()
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
@@ -33,10 +50,10 @@ class Config {
)
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
const isQuizzExists = fs.readdirSync(getPath("quizz")).length > 0
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
fs.mkdirSync(getPath("quizz"), { recursive: true })
fs.writeFileSync(
getPath("quizz/example.json"),
@@ -67,6 +84,49 @@ class Config {
cooldown: 5,
time: 20,
},
{
question: "Which soundtrack is this?",
answers: [
"Nature sounds",
"Piano solo",
"Electronic beat",
"Chill guitar",
],
media: {
type: "audio",
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
},
solution: 1,
cooldown: 5,
time: 25,
},
{
question: "Which landmark appears in this clip?",
answers: [
"Eiffel Tower",
"Sydney Opera House",
"Statue of Liberty",
"Golden Gate Bridge",
],
media: {
type: "youtube",
url: "https://www.youtube.com/watch?v=jNQXAC9IVRw",
},
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,
cooldown: 5,
time: 40,
},
],
},
null,
@@ -95,13 +155,8 @@ class Config {
}
static quizz() {
const isExists = fs.existsSync(getPath("quizz"))
this.ensureBaseFolders()
if (!isExists) {
return []
}
try {
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
@@ -119,11 +174,47 @@ class Config {
})
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
return []
}
static getQuizz(id: string) {
this.ensureBaseFolders()
const filePath = getPath(`quizz/${id}.json`)
if (!fs.existsSync(filePath)) {
return null
}
const data = fs.readFileSync(filePath, "utf-8")
return { id, ...JSON.parse(data) } as QuizzWithId
}
static saveQuizz(
id: string | null,
quizz: QuizzWithId | Omit<QuizzWithId, "id">
) {
this.ensureBaseFolders()
const slug = id
? slugify(id)
: slugify((quizz as any).subject || "quizz")
const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}`
const filePath = getPath(`quizz/${finalId}.json`)
fs.writeFileSync(
filePath,
JSON.stringify(
{
subject: quizz.subject,
questions: quizz.questions,
},
null,
2
)
)
return this.getQuizz(finalId)
}
}

View File

@@ -346,6 +346,7 @@ class Game {
this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question,
image: question.image,
media: question.media,
cooldown: question.cooldown,
})
@@ -361,6 +362,7 @@ class Game {
question: question.question,
answers: question.answers,
image: question.image,
media: question.media,
time: question.time,
totalPlayer: this.players.length,
})
@@ -430,6 +432,7 @@ class Game {
correct: question.solution,
answers: question.answers,
image: question.image,
media: question.media,
})
this.leaderboard = sortedPlayers

View File

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

View File

@@ -3,6 +3,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 SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager"
@@ -16,6 +17,7 @@ const Manager = () => {
const [isAuth, setIsAuth] = useState(false)
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
const [showEditor, setShowEditor] = useState(false)
useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true)
@@ -39,7 +41,23 @@ const Manager = () => {
return <ManagerPassword onSubmit={handleAuth} />
}
return <SelectQuizz quizzList={quizzList} onSelect={handleCreate} />
if (showEditor) {
return (
<QuizEditor
quizzList={quizzList}
onBack={() => setShowEditor(false)}
onListUpdate={setQuizzList}
/>
)
}
return (
<SelectQuizz
quizzList={quizzList}
onSelect={handleCreate}
onManage={() => setShowEditor(true)}
/>
)
}
export default Manager

View File

@@ -0,0 +1,270 @@
"use client"
import { QuestionMedia } from "@rahoot/common/types/game"
import clsx from "clsx"
import { useEffect, useMemo, useRef, 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 = {
media?: QuestionMedia
alt: string
onPlayChange?: (_playing: boolean) => void
}
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
const youtubeContainerId = useMemo(
() => `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) {
return null
}
const containerClass = "mx-auto flex w-full max-w-3xl justify-center"
switch (media.type) {
case "image":
return (
<div className={containerClass}>
<img
alt={alt}
src={media.url}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full rounded-md object-contain shadow-lg"
/>
</div>
)
case "audio":
return (
<div className={clsx(containerClass, "px-4")}>
<audio
controls
src={media.url}
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
preload="none"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
case "video":
return (
<div className={containerClass}>
<video
controls
src={media.url}
className="m-4 w-full max-w-3xl rounded-md shadow-lg"
preload="metadata"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
case "youtube": {
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
}
export default QuestionMedia

View File

@@ -0,0 +1,398 @@
"use client"
import { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
import Button from "@rahoot/web/components/Button"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import clsx from "clsx"
import { useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
type Props = {
quizzList: QuizzWithId[]
onBack: () => void
onListUpdate: (_quizz: QuizzWithId[]) => void
}
type EditableQuestion = QuizzWithId["questions"][number]
const blankQuestion = (): EditableQuestion => ({
question: "",
answers: ["", ""],
solution: 0,
cooldown: 5,
time: 20,
})
const mediaTypes: QuestionMedia["type"][] = [
"image",
"audio",
"video",
"youtube",
]
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const { socket } = useSocket()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<QuizzWithId | null>(null)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
useEvent("manager:quizzLoaded", (quizz) => {
setDraft(quizz)
setLoading(false)
})
useEvent("manager:quizzSaved", (quizz) => {
toast.success("Quiz saved")
setDraft(quizz)
setSelectedId(quizz.id)
setSaving(false)
})
useEvent("manager:quizzList", (list) => {
onListUpdate(list)
})
useEvent("manager:errorMessage", (message) => {
toast.error(message)
setSaving(false)
setLoading(false)
})
const handleLoad = (id: string) => {
setSelectedId(id)
setLoading(true)
socket?.emit("manager:getQuizz", id)
}
const handleNew = () => {
setSelectedId(null)
setDraft({
id: "",
subject: "",
questions: [blankQuestion()],
})
}
const updateQuestion = (
index: number,
patch: Partial<EditableQuestion>,
) => {
if (!draft) return
const nextQuestions = [...draft.questions]
nextQuestions[index] = { ...nextQuestions[index], ...patch }
setDraft({ ...draft, questions: nextQuestions })
}
const updateAnswer = (qIndex: number, aIndex: number, value: string) => {
if (!draft) return
const nextQuestions = [...draft.questions]
const nextAnswers = [...nextQuestions[qIndex].answers]
nextAnswers[aIndex] = value
nextQuestions[qIndex] = { ...nextQuestions[qIndex], answers: nextAnswers }
setDraft({ ...draft, questions: nextQuestions })
}
const addAnswer = (qIndex: number) => {
if (!draft) return
const nextQuestions = [...draft.questions]
if (nextQuestions[qIndex].answers.length >= 4) {
return
}
nextQuestions[qIndex] = {
...nextQuestions[qIndex],
answers: [...nextQuestions[qIndex].answers, ""],
}
setDraft({ ...draft, questions: nextQuestions })
}
const removeAnswer = (qIndex: number, aIndex: number) => {
if (!draft) return
const nextQuestions = [...draft.questions]
const currentAnswers = [...nextQuestions[qIndex].answers]
if (currentAnswers.length <= 2) {
return
}
currentAnswers.splice(aIndex, 1)
let nextSolution = nextQuestions[qIndex].solution
if (nextSolution >= currentAnswers.length) {
nextSolution = currentAnswers.length - 1
}
nextQuestions[qIndex] = {
...nextQuestions[qIndex],
answers: currentAnswers,
solution: nextSolution,
}
setDraft({ ...draft, questions: nextQuestions })
}
const addQuestion = () => {
if (!draft) return
setDraft({ ...draft, questions: [...draft.questions, blankQuestion()] })
}
const removeQuestion = (index: number) => {
if (!draft || draft.questions.length <= 1) return
const nextQuestions = draft.questions.filter((_, i) => i !== index)
setDraft({ ...draft, questions: nextQuestions })
}
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
if (!draft) return
const question = draft.questions[qIndex]
const nextMedia =
type === "" ? undefined : { type, url: question.media?.url || "" }
updateQuestion(qIndex, { media: nextMedia })
}
const handleSave = () => {
if (!draft) return
setSaving(true)
socket?.emit("manager:saveQuizz", {
id: draft.id || null,
quizz: {
subject: draft.subject,
questions: draft.questions,
},
})
}
const selectedLabel = useMemo(() => {
if (!selectedId) return "New quiz"
const found = quizzList.find((q) => q.id === selectedId)
return found ? `Editing: ${found.subject}` : `Editing: ${selectedId}`
}, [quizzList, selectedId])
return (
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button onClick={onBack} className="bg-gray-700">
Back
</Button>
<Button onClick={handleNew} className="bg-blue-600">
New quiz
</Button>
</div>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : "Save quiz"}
</Button>
</div>
<div className="flex flex-col gap-3 rounded-md border border-gray-200 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-600">
Existing quizzes:
</span>
{quizzList.map((quizz) => (
<button
key={quizz.id}
onClick={() => handleLoad(quizz.id)}
className={clsx(
"rounded-sm border px-3 py-1 text-sm font-semibold",
selectedId === quizz.id
? "border-primary text-primary"
: "border-gray-300",
)}
>
{quizz.subject}
</button>
))}
</div>
</div>
{!draft && (
<div className="rounded-md border border-dashed border-gray-300 p-6 text-center text-gray-600">
{loading ? "Loading quiz..." : "Select a quiz to edit or create a new one."}
</div>
)}
{draft && (
<div className="space-y-4">
<div className="rounded-md border border-gray-200 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700">
{selectedLabel}
</div>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Subject</span>
<Input
value={draft.subject}
onChange={(e) => setDraft({ ...draft, subject: e.target.value })}
placeholder="Quiz title"
/>
</label>
</div>
{draft.questions.map((question, qIndex) => (
<div
key={qIndex}
className="rounded-md border border-gray-200 p-4 shadow-sm"
>
<div className="mb-3 flex items-center justify-between">
<div className="text-lg font-semibold text-gray-800">
Question {qIndex + 1}
</div>
<div className="flex gap-2">
<Button
className="bg-red-500"
onClick={() => removeQuestion(qIndex)}
disabled={draft.questions.length <= 1}
>
Remove
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Prompt</span>
<Input
value={question.question}
onChange={(e) =>
updateQuestion(qIndex, { question: e.target.value })
}
placeholder="Enter the question"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Cooldown (s)</span>
<Input
type="number"
value={question.cooldown}
onChange={(e) =>
updateQuestion(qIndex, {
cooldown: Number(e.target.value || 0),
})
}
min={0}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Answer time (s)</span>
<Input
type="number"
value={question.time}
onChange={(e) =>
updateQuestion(qIndex, { time: Number(e.target.value || 0) })
}
min={5}
/>
</label>
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Media type
</span>
<select
className="rounded-sm border border-gray-300 p-2 font-semibold"
value={question.media?.type || ""}
onChange={(e) =>
handleMediaType(qIndex, e.target.value as QuestionMedia["type"] | "")
}
>
<option value="">None</option>
{mediaTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Media URL
</span>
<Input
value={question.media?.url || question.image || ""}
onChange={(e) =>
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://..."
/>
<span className="text-xs text-gray-500">
Tip: set answer time longer than the clip duration.
</span>
</label>
</div>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Answers</span>
<Button
className="bg-blue-600"
onClick={() => addAnswer(qIndex)}
disabled={question.answers.length >= 4}
>
Add answer
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{question.answers.map((answer, aIndex) => (
<div
key={aIndex}
className={clsx(
"flex items-center gap-2 rounded-md border p-2",
question.solution === aIndex
? "border-green-500"
: "border-gray-200",
)}
>
<input
type="radio"
name={`solution-${qIndex}`}
checked={question.solution === aIndex}
onChange={() =>
updateQuestion(qIndex, { solution: aIndex })
}
/>
<Input
className="flex-1"
value={answer}
onChange={(e) =>
updateAnswer(qIndex, aIndex, e.target.value)
}
placeholder={`Answer ${aIndex + 1}`}
/>
<button
className="rounded-sm px-2 py-1 text-sm font-semibold text-red-500"
onClick={() => removeAnswer(qIndex, aIndex)}
disabled={question.answers.length <= 2}
>
Remove
</button>
</div>
))}
</div>
</div>
</div>
))}
<div className="flex justify-center">
<Button className="bg-blue-600" onClick={addQuestion}>
Add question
</Button>
</div>
</div>
)}
</div>
)
}
export default QuizEditor

View File

@@ -7,9 +7,10 @@ import toast from "react-hot-toast"
type Props = {
quizzList: QuizzWithId[]
onSelect: (_id: string) => void
onManage?: () => void
}
const SelectQuizz = ({ quizzList, onSelect }: Props) => {
const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => {
@@ -32,8 +33,18 @@ const SelectQuizz = ({ quizzList, onSelect }: Props) => {
return (
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Select a quizz</h1>
{onManage && (
<button
className="text-sm font-semibold text-primary underline"
onClick={onManage}
>
Manage
</button>
)}
</div>
<div className="flex flex-col items-center justify-center">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button

View File

@@ -2,6 +2,7 @@
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import {
@@ -20,7 +21,7 @@ type Props = {
}
const Answers = ({
data: { question, answers, image, time, totalPlayer },
data: { question, answers, image, media, time, totalPlayer },
}: Props) => {
const { gameId }: { gameId?: string } = useParams()
const { socket } = useSocket()
@@ -28,16 +29,20 @@ const Answers = ({
const [cooldown, setCooldown] = useState(time)
const [totalAnswer, setTotalAnswer] = useState(0)
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
volume: 0.1,
})
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
SFX_ANSWERS_MUSIC,
{
volume: 0.2,
interrupt: true,
loop: true,
})
},
)
const handleAnswer = (answerKey: number) => () => {
if (!player) {
@@ -61,6 +66,14 @@ const Answers = ({
}
}, [playMusic])
useEffect(() => {
if (!answersMusic) {
return
}
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
}, [answersMusic, isMediaPlaying])
useEvent("game:cooldown", (sec) => {
setCooldown(sec)
})
@@ -77,13 +90,11 @@ const Answers = ({
{question}
</h2>
{Boolean(image) && (
<img
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
src={image}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
onPlayChange={(playing) => setIsMediaPlaying(playing)}
/>
)}
</div>
<div>

View File

@@ -1,6 +1,7 @@
"use client"
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 useSound from "use-sound"
@@ -9,7 +10,7 @@ type Props = {
data: CommonStatusDataMap["SHOW_QUESTION"]
}
const Question = ({ data: { question, image, cooldown } }: Props) => {
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
useEffect(() => {
@@ -23,13 +24,10 @@ const Question = ({ data: { question, image, cooldown } }: Props) => {
{question}
</h2>
{Boolean(image) && (
<img
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
src={image}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
/>
)}
</div>
<div
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"

View File

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

View File

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