diff --git a/README.md b/README.md index 2cd82a9..dbfd6cc 100644 --- a/README.md +++ b/README.md @@ -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": "" }`. 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 diff --git a/config/quizz/example.json b/config/quizz/example.json index f49a60a..37d3051 100644 --- a/config/quizz/example.json +++ b/config/quizz/example.json @@ -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 } ] -} +} \ No newline at end of file diff --git a/packages/common/src/types/game/index.ts b/packages/common/src/types/game/index.ts index 447dc2d..d1e3547 100644 --- a/packages/common/src/types/game/index.ts +++ b/packages/common/src/types/game/index.ts @@ -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 = { diff --git a/packages/common/src/types/game/status.ts b/packages/common/src/types/game/status.ts index ef1ba17..39ecfad 100644 --- a/packages/common/src/types/game/status.ts +++ b/packages/common/src/types/game/status.ts @@ -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[] } } diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index 1b9eaeb..722a179 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -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) diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts index 92db828..f515fbb 100644 --- a/packages/socket/src/services/config.ts +++ b/packages/socket/src/services/config.ts @@ -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,35 +155,66 @@ class Config { } static quizz() { - const isExists = fs.existsSync(getPath("quizz")) + this.ensureBaseFolders() - if (!isExists) { - return [] + const files = fs + .readdirSync(getPath("quizz")) + .filter((file) => file.endsWith(".json")) + + const quizz: QuizzWithId[] = files.map((file) => { + const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8") + const config = JSON.parse(data) + + const id = file.replace(".json", "") + + return { + id, + ...config, + } + }) + + return quizz || [] + } + + static getQuizz(id: string) { + this.ensureBaseFolders() + + const filePath = getPath(`quizz/${id}.json`) + + if (!fs.existsSync(filePath)) { + return null } - try { - const files = fs - .readdirSync(getPath("quizz")) - .filter((file) => file.endsWith(".json")) + const data = fs.readFileSync(filePath, "utf-8") - const quizz: QuizzWithId[] = files.map((file) => { - const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8") - const config = JSON.parse(data) + return { id, ...JSON.parse(data) } as QuizzWithId + } - const id = file.replace(".json", "") + static saveQuizz( + id: string | null, + quizz: QuizzWithId | Omit + ) { + this.ensureBaseFolders() - return { - id, - ...config, - } - }) + const slug = id + ? slugify(id) + : slugify((quizz as any).subject || "quizz") + const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}` + const filePath = getPath(`quizz/${finalId}.json`) - return quizz || [] - } catch (error) { - console.error("Failed to read quizz config:", error) + fs.writeFileSync( + filePath, + JSON.stringify( + { + subject: quizz.subject, + questions: quizz.questions, + }, + null, + 2 + ) + ) - return [] - } + return this.getQuizz(finalId) } } diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index 871e2be..b67f608 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -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 diff --git a/packages/web/src/app/(auth)/layout.tsx b/packages/web/src/app/(auth)/layout.tsx index cda8be6..071be10 100644 --- a/packages/web/src/app/(auth)/layout.tsx +++ b/packages/web/src/app/(auth)/layout.tsx @@ -17,7 +17,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => { if (!isConnected) { return (
-
+
@@ -33,7 +33,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => { return (
-
+
diff --git a/packages/web/src/app/(auth)/manager/page.tsx b/packages/web/src/app/(auth)/manager/page.tsx index cf9ce12..1229377 100644 --- a/packages/web/src/app/(auth)/manager/page.tsx +++ b/packages/web/src/app/(auth)/manager/page.tsx @@ -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([]) + const [showEditor, setShowEditor] = useState(false) useEvent("manager:quizzList", (quizzList) => { setIsAuth(true) @@ -39,7 +41,23 @@ const Manager = () => { return } - return + if (showEditor) { + return ( + setShowEditor(false)} + onListUpdate={setQuizzList} + /> + ) + } + + return ( + setShowEditor(true)} + /> + ) } export default Manager diff --git a/packages/web/src/components/game/QuestionMedia.tsx b/packages/web/src/components/game/QuestionMedia.tsx new file mode 100644 index 0000000..396f554 --- /dev/null +++ b/packages/web/src/components/game/QuestionMedia.tsx @@ -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 +} + +let youtubeApiPromise: Promise | 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(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 ( +
+ {alt} +
+ ) + + case "audio": + return ( +
+
+ ) + + case "video": + return ( +
+
+ ) + + case "youtube": { + return ( +
+
+
+
+
+ + +
+
+
+ ) + } + } + + return null +} + +export default QuestionMedia diff --git a/packages/web/src/components/game/create/QuizEditor.tsx b/packages/web/src/components/game/create/QuizEditor.tsx new file mode 100644 index 0000000..5e76380 --- /dev/null +++ b/packages/web/src/components/game/create/QuizEditor.tsx @@ -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(null) + const [draft, setDraft] = useState(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, + ) => { + 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 ( +
+
+
+ + +
+ + +
+ +
+
+ + Existing quizzes: + + {quizzList.map((quizz) => ( + + ))} +
+
+ + {!draft && ( +
+ {loading ? "Loading quiz..." : "Select a quiz to edit or create a new one."} +
+ )} + + {draft && ( +
+
+
+ {selectedLabel} +
+ +
+ + {draft.questions.map((question, qIndex) => ( +
+
+
+ Question {qIndex + 1} +
+
+ +
+
+ +
+ + +
+ + +
+
+ +
+ + + +
+ +
+
+ Answers + +
+ +
+ {question.answers.map((answer, aIndex) => ( +
+ + updateQuestion(qIndex, { solution: aIndex }) + } + /> + + updateAnswer(qIndex, aIndex, e.target.value) + } + placeholder={`Answer ${aIndex + 1}`} + /> + +
+ ))} +
+
+
+ ))} + +
+ +
+
+ )} +
+ ) +} + +export default QuizEditor diff --git a/packages/web/src/components/game/create/SelectQuizz.tsx b/packages/web/src/components/game/create/SelectQuizz.tsx index 687b872..56525a6 100644 --- a/packages/web/src/components/game/create/SelectQuizz.tsx +++ b/packages/web/src/components/game/create/SelectQuizz.tsx @@ -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(null) const handleSelect = (id: string) => () => { @@ -32,8 +33,18 @@ const SelectQuizz = ({ quizzList, onSelect }: Props) => { return (
+
+

Select a quizz

+ {onManage && ( + + )} +
-

Select a quizz

{quizzList.map((quizz) => (
diff --git a/packages/web/src/components/game/states/Question.tsx b/packages/web/src/components/game/states/Question.tsx index ab098f5..07e6f64 100644 --- a/packages/web/src/components/game/states/Question.tsx +++ b/packages/web/src/components/game/states/Question.tsx @@ -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} - {Boolean(image) && ( - {question} - )} +
{ const [percentages, setPercentages] = useState>({}) const [isMusicPlaying, setIsMusicPlaying] = useState(false) + const [isMediaPlaying, setIsMediaPlaying] = useState(false) const [sfxResults] = useSound(SFX_RESULTS_SOUND, { volume: 0.2, }) - const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, { - volume: 0.2, - onplay: () => { - setIsMusicPlaying(true) + const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound( + SFX_ANSWERS_MUSIC, + { + volume: 0.2, + onplay: () => { + setIsMusicPlaying(true) + }, + onend: () => { + setIsMusicPlaying(false) + }, }, - onend: () => { - setIsMusicPlaying(false) - }, - }) + ) useEffect(() => { stopMusic() @@ -50,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} + setIsMediaPlaying(playing)} + /> +
({ }) const getSocketServer = async () => { - const res = await ky.get("/socket").json<{ url: string }>() + try { + const res = await ky.get("/socket").json<{ url: string }>() + if (res.url) return res.url + } catch (error) { + console.error("Failed to fetch socket url, using fallback", error) + } - return res.url + if (typeof window !== "undefined") { + const { protocol, hostname } = window.location + const isHttps = protocol === "https:" + const port = + window.location.port && window.location.port !== "3000" + ? window.location.port + : "3001" + const scheme = isHttps ? "https:" : "http:" + + return `${scheme}//${hostname}:${port}` + } + + return "http://localhost:3001" } const getClientId = (): string => { @@ -75,12 +92,20 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { try { const socketUrl = await getSocketServer() + const isHttps = socketUrl.startsWith("https") + s = io(socketUrl, { - transports: ["websocket"], + transports: ["websocket", "polling"], autoConnect: false, + withCredentials: false, + forceNew: true, + secure: isHttps, auth: { clientId, }, + reconnection: true, + reconnectionAttempts: 5, + timeout: 12000, }) setSocket(s) @@ -94,7 +119,10 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => { }) s.on("connect_error", (err) => { - console.error("Connection error:", err.message) + console.error("Connection error:", err.message, { + url: socketUrl, + transport: s?.io?.opts?.transports, + }) }) } catch (error) { console.error("Failed to initialize socket:", error)