mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding manager UI and audio and video (youtube) questions
This commit is contained in:
@@ -134,6 +134,7 @@ Example quiz configuration (`config/quizz/example.json`):
|
|||||||
"question": "What is the correct answer?",
|
"question": "What is the correct answer?",
|
||||||
"answers": ["No", "Yes", "No", "No"],
|
"answers": ["No", "Yes", "No", "No"],
|
||||||
"image": "https://images.unsplash.com/....",
|
"image": "https://images.unsplash.com/....",
|
||||||
|
"media": { "type": "audio", "url": "https://example.com/song.mp3" },
|
||||||
"solution": 1,
|
"solution": 1,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
"time": 15
|
"time": 15
|
||||||
@@ -148,11 +149,17 @@ Quiz Options:
|
|||||||
- `questions`: Array of question objects containing:
|
- `questions`: Array of question objects containing:
|
||||||
- `question`: The question text
|
- `question`: The question text
|
||||||
- `answers`: Array of possible answers (2-4 options)
|
- `answers`: Array of possible answers (2-4 options)
|
||||||
- `image`: Optional URL for question image
|
- `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)
|
- `solution`: Index of correct answer (0-based)
|
||||||
- `cooldown`: Time in seconds before showing the question
|
- `cooldown`: Time in seconds before showing the question
|
||||||
- `time`: Time in seconds allowed to answer
|
- `time`: Time in seconds allowed to answer
|
||||||
|
|
||||||
|
Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”).
|
||||||
|
|
||||||
## 🎮 How to Play
|
## 🎮 How to Play
|
||||||
|
|
||||||
1. Access the manager interface at http://localhost:3000/manager
|
1. Access the manager interface at http://localhost:3000/manager
|
||||||
|
|||||||
@@ -2,27 +2,52 @@
|
|||||||
"subject": "Example Quizz",
|
"subject": "Example Quizz",
|
||||||
"questions": [
|
"questions": [
|
||||||
{
|
{
|
||||||
"question": "What is good answer ?",
|
"question": "Which soundtrack is this?",
|
||||||
"answers": ["No", "Good answer", "No", "No"],
|
"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,
|
"solution": 1,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
"time": 15
|
"time": 25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "What is good answer with image ?",
|
"question": "Which landmark appears in this clip?",
|
||||||
"answers": ["No", "No", "No", "Good answer"],
|
"answers": [
|
||||||
"image": "https://placehold.co/600x400.png",
|
"Eiffel Tower",
|
||||||
|
"Sydney Opera House",
|
||||||
|
"Statue of Liberty",
|
||||||
|
"Golden Gate Bridge"
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"type": "youtube",
|
||||||
|
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||||
|
},
|
||||||
"solution": 3,
|
"solution": 3,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
"time": 20
|
"time": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "What is good answer with two answers ?",
|
"question": "What kind of animal is featured here?",
|
||||||
"answers": ["Good answer", "No"],
|
"answers": [
|
||||||
"image": "https://placehold.co/600x400.png",
|
"Dolphin",
|
||||||
"solution": 0,
|
"Panda",
|
||||||
|
"Horse",
|
||||||
|
"Penguin"
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"type": "youtube",
|
||||||
|
"url": "https://www.youtube.com/watch?v=2k1qW3D0q6c"
|
||||||
|
},
|
||||||
|
"solution": 2,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
"time": 20
|
"time": 40
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ export type Quizz = {
|
|||||||
questions: {
|
questions: {
|
||||||
question: string
|
question: string
|
||||||
image?: string
|
image?: string
|
||||||
|
media?: QuestionMedia
|
||||||
answers: string[]
|
answers: string[]
|
||||||
solution: number
|
solution: number
|
||||||
cooldown: 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 QuizzWithId = Quizz & { id: string }
|
||||||
|
|
||||||
export type GameUpdateQuestion = {
|
export type GameUpdateQuestion = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Player } from "."
|
import { Player, QuestionMedia } from "."
|
||||||
|
|
||||||
export const STATUS = {
|
export const STATUS = {
|
||||||
SHOW_ROOM: "SHOW_ROOM",
|
SHOW_ROOM: "SHOW_ROOM",
|
||||||
@@ -18,11 +18,17 @@ export type Status = (typeof STATUS)[keyof typeof STATUS]
|
|||||||
export type CommonStatusDataMap = {
|
export type CommonStatusDataMap = {
|
||||||
SHOW_START: { time: number; subject: string }
|
SHOW_START: { time: number; subject: string }
|
||||||
SHOW_PREPARED: { totalAnswers: number; questionNumber: number }
|
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: {
|
SELECT_ANSWER: {
|
||||||
question: string
|
question: string
|
||||||
answers: string[]
|
answers: string[]
|
||||||
image?: string
|
image?: string
|
||||||
|
media?: QuestionMedia
|
||||||
time: number
|
time: number
|
||||||
totalPlayer: number
|
totalPlayer: number
|
||||||
}
|
}
|
||||||
@@ -46,6 +52,7 @@ type ManagerExtraStatus = {
|
|||||||
correct: number
|
correct: number
|
||||||
answers: string[]
|
answers: string[]
|
||||||
image?: string
|
image?: string
|
||||||
|
media?: QuestionMedia
|
||||||
}
|
}
|
||||||
SHOW_LEADERBOARD: { oldLeaderboard: Player[]; leaderboard: Player[] }
|
SHOW_LEADERBOARD: { oldLeaderboard: Player[]; leaderboard: Player[] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ import Registry from "@rahoot/socket/services/registry"
|
|||||||
import { withGame } from "@rahoot/socket/utils/game"
|
import { withGame } from "@rahoot/socket/utils/game"
|
||||||
import { Server as ServerIO } from "socket.io"
|
import { Server as ServerIO } from "socket.io"
|
||||||
|
|
||||||
|
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({
|
const io: Server = new ServerIO({
|
||||||
cors: {
|
cors: {
|
||||||
origin: [env.WEB_ORIGIN],
|
origin: corsOrigins,
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
credentials: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Config.init()
|
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) => {
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { QuizzWithId } from "@rahoot/common/types/game"
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { resolve } from "path"
|
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 inContainerPath = process.env.CONFIG_PATH
|
||||||
|
|
||||||
const getPath = (path: string = "") =>
|
const getPath = (path: string = "") =>
|
||||||
@@ -10,13 +17,23 @@ const getPath = (path: string = "") =>
|
|||||||
: resolve(process.cwd(), "../../config", path)
|
: resolve(process.cwd(), "../../config", path)
|
||||||
|
|
||||||
class Config {
|
class Config {
|
||||||
static init() {
|
static ensureBaseFolders() {
|
||||||
const isConfigFolderExists = fs.existsSync(getPath())
|
const isConfigFolderExists = fs.existsSync(getPath())
|
||||||
|
|
||||||
if (!isConfigFolderExists) {
|
if (!isConfigFolderExists) {
|
||||||
fs.mkdirSync(getPath())
|
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"))
|
const isGameConfigExists = fs.existsSync(getPath("game.json"))
|
||||||
|
|
||||||
if (!isGameConfigExists) {
|
if (!isGameConfigExists) {
|
||||||
@@ -33,10 +50,10 @@ class Config {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuizzExists = fs.existsSync(getPath("quizz"))
|
const isQuizzExists = fs.readdirSync(getPath("quizz")).length > 0
|
||||||
|
|
||||||
if (!isQuizzExists) {
|
if (!isQuizzExists) {
|
||||||
fs.mkdirSync(getPath("quizz"))
|
fs.mkdirSync(getPath("quizz"), { recursive: true })
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
getPath("quizz/example.json"),
|
getPath("quizz/example.json"),
|
||||||
@@ -67,6 +84,49 @@ class Config {
|
|||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 20,
|
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,
|
null,
|
||||||
@@ -95,35 +155,66 @@ class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static quizz() {
|
static quizz() {
|
||||||
const isExists = fs.existsSync(getPath("quizz"))
|
this.ensureBaseFolders()
|
||||||
|
|
||||||
if (!isExists) {
|
const files = fs
|
||||||
return []
|
.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 data = fs.readFileSync(filePath, "utf-8")
|
||||||
const files = fs
|
|
||||||
.readdirSync(getPath("quizz"))
|
|
||||||
.filter((file) => file.endsWith(".json"))
|
|
||||||
|
|
||||||
const quizz: QuizzWithId[] = files.map((file) => {
|
return { id, ...JSON.parse(data) } as QuizzWithId
|
||||||
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
|
}
|
||||||
const config = JSON.parse(data)
|
|
||||||
|
|
||||||
const id = file.replace(".json", "")
|
static saveQuizz(
|
||||||
|
id: string | null,
|
||||||
|
quizz: QuizzWithId | Omit<QuizzWithId, "id">
|
||||||
|
) {
|
||||||
|
this.ensureBaseFolders()
|
||||||
|
|
||||||
return {
|
const slug = id
|
||||||
id,
|
? slugify(id)
|
||||||
...config,
|
: slugify((quizz as any).subject || "quizz")
|
||||||
}
|
const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}`
|
||||||
})
|
const filePath = getPath(`quizz/${finalId}.json`)
|
||||||
|
|
||||||
return quizz || []
|
fs.writeFileSync(
|
||||||
} catch (error) {
|
filePath,
|
||||||
console.error("Failed to read quizz config:", error)
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
subject: quizz.subject,
|
||||||
|
questions: quizz.questions,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return []
|
return this.getQuizz(finalId)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ class Game {
|
|||||||
this.broadcastStatus(STATUS.SHOW_QUESTION, {
|
this.broadcastStatus(STATUS.SHOW_QUESTION, {
|
||||||
question: question.question,
|
question: question.question,
|
||||||
image: question.image,
|
image: question.image,
|
||||||
|
media: question.media,
|
||||||
cooldown: question.cooldown,
|
cooldown: question.cooldown,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -361,6 +362,7 @@ class Game {
|
|||||||
question: question.question,
|
question: question.question,
|
||||||
answers: question.answers,
|
answers: question.answers,
|
||||||
image: question.image,
|
image: question.image,
|
||||||
|
media: question.media,
|
||||||
time: question.time,
|
time: question.time,
|
||||||
totalPlayer: this.players.length,
|
totalPlayer: this.players.length,
|
||||||
})
|
})
|
||||||
@@ -430,6 +432,7 @@ class Game {
|
|||||||
correct: question.solution,
|
correct: question.solution,
|
||||||
answers: question.answers,
|
answers: question.answers,
|
||||||
image: question.image,
|
image: question.image,
|
||||||
|
media: question.media,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.leaderboard = sortedPlayers
|
this.leaderboard = sortedPlayers
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
|
|||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
<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 -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 className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
<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 -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 className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { QuizzWithId } from "@rahoot/common/types/game"
|
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 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"
|
||||||
@@ -16,6 +17,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)
|
||||||
|
|
||||||
useEvent("manager:quizzList", (quizzList) => {
|
useEvent("manager:quizzList", (quizzList) => {
|
||||||
setIsAuth(true)
|
setIsAuth(true)
|
||||||
@@ -39,7 +41,23 @@ const Manager = () => {
|
|||||||
return <ManagerPassword onSubmit={handleAuth} />
|
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
|
export default Manager
|
||||||
|
|||||||
270
packages/web/src/components/game/QuestionMedia.tsx
Normal file
270
packages/web/src/components/game/QuestionMedia.tsx
Normal 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
|
||||||
398
packages/web/src/components/game/create/QuizEditor.tsx
Normal file
398
packages/web/src/components/game/create/QuizEditor.tsx
Normal 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
|
||||||
@@ -7,9 +7,10 @@ import toast from "react-hot-toast"
|
|||||||
type Props = {
|
type Props = {
|
||||||
quizzList: QuizzWithId[]
|
quizzList: QuizzWithId[]
|
||||||
onSelect: (_id: string) => void
|
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 [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSelect = (id: string) => () => {
|
const handleSelect = (id: string) => () => {
|
||||||
@@ -32,8 +33,18 @@ const SelectQuizz = ({ quizzList, onSelect }: Props) => {
|
|||||||
|
|
||||||
return (
|
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="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">
|
<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">
|
<div className="w-full space-y-2">
|
||||||
{quizzList.map((quizz) => (
|
{quizzList.map((quizz) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||||
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
||||||
|
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Answers = ({
|
const Answers = ({
|
||||||
data: { question, answers, image, time, totalPlayer },
|
data: { question, answers, image, media, time, totalPlayer },
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { gameId }: { gameId?: string } = useParams()
|
const { gameId }: { gameId?: string } = useParams()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
@@ -28,16 +29,20 @@ const Answers = ({
|
|||||||
|
|
||||||
const [cooldown, setCooldown] = useState(time)
|
const [cooldown, setCooldown] = useState(time)
|
||||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||||
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
|
|
||||||
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
||||||
volume: 0.1,
|
volume: 0.1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
|
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
|
||||||
volume: 0.2,
|
SFX_ANSWERS_MUSIC,
|
||||||
interrupt: true,
|
{
|
||||||
loop: true,
|
volume: 0.2,
|
||||||
})
|
interrupt: true,
|
||||||
|
loop: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const handleAnswer = (answerKey: number) => () => {
|
const handleAnswer = (answerKey: number) => () => {
|
||||||
if (!player) {
|
if (!player) {
|
||||||
@@ -61,6 +66,14 @@ const Answers = ({
|
|||||||
}
|
}
|
||||||
}, [playMusic])
|
}, [playMusic])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!answersMusic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
|
||||||
|
}, [answersMusic, isMediaPlaying])
|
||||||
|
|
||||||
useEvent("game:cooldown", (sec) => {
|
useEvent("game:cooldown", (sec) => {
|
||||||
setCooldown(sec)
|
setCooldown(sec)
|
||||||
})
|
})
|
||||||
@@ -77,13 +90,11 @@ const Answers = ({
|
|||||||
{question}
|
{question}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{Boolean(image) && (
|
<QuestionMedia
|
||||||
<img
|
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||||
alt={question}
|
alt={question}
|
||||||
src={image}
|
onPlayChange={(playing) => setIsMediaPlaying(playing)}
|
||||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
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 { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
|
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import useSound from "use-sound"
|
import useSound from "use-sound"
|
||||||
@@ -9,7 +10,7 @@ type Props = {
|
|||||||
data: CommonStatusDataMap["SHOW_QUESTION"]
|
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 })
|
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,13 +24,10 @@ const Question = ({ data: { question, image, cooldown } }: Props) => {
|
|||||||
{question}
|
{question}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{Boolean(image) && (
|
<QuestionMedia
|
||||||
<img
|
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||||
alt={question}
|
alt={question}
|
||||||
src={image}
|
/>
|
||||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
|
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
|
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
|
||||||
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
||||||
|
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||||
import {
|
import {
|
||||||
ANSWERS_COLORS,
|
ANSWERS_COLORS,
|
||||||
ANSWERS_ICONS,
|
ANSWERS_ICONS,
|
||||||
@@ -18,24 +19,28 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Responses = ({
|
const Responses = ({
|
||||||
data: { question, answers, responses, correct },
|
data: { question, answers, responses, correct, image, media },
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [percentages, setPercentages] = useState<Record<string, string>>({})
|
const [percentages, setPercentages] = useState<Record<string, string>>({})
|
||||||
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
|
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
|
||||||
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
|
|
||||||
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
||||||
volume: 0.2,
|
volume: 0.2,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
|
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
|
||||||
volume: 0.2,
|
SFX_ANSWERS_MUSIC,
|
||||||
onplay: () => {
|
{
|
||||||
setIsMusicPlaying(true)
|
volume: 0.2,
|
||||||
|
onplay: () => {
|
||||||
|
setIsMusicPlaying(true)
|
||||||
|
},
|
||||||
|
onend: () => {
|
||||||
|
setIsMusicPlaying(false)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onend: () => {
|
)
|
||||||
setIsMusicPlaying(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stopMusic()
|
stopMusic()
|
||||||
@@ -50,6 +55,14 @@ const Responses = ({
|
|||||||
}
|
}
|
||||||
}, [isMusicPlaying, playMusic])
|
}, [isMusicPlaying, playMusic])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!answersMusic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
|
||||||
|
}, [answersMusic, isMediaPlaying])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stopMusic()
|
stopMusic()
|
||||||
}, [playMusic, stopMusic])
|
}, [playMusic, stopMusic])
|
||||||
@@ -61,6 +74,12 @@ const Responses = ({
|
|||||||
{question}
|
{question}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<QuestionMedia
|
||||||
|
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||||
|
alt={question}
|
||||||
|
onPlayChange={(playing) => setIsMediaPlaying(playing)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
|
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
|
||||||
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
|
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
|
||||||
|
|||||||
@@ -37,9 +37,26 @@ const SocketContext = createContext<SocketContextValue>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getSocketServer = async () => {
|
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 => {
|
const getClientId = (): string => {
|
||||||
@@ -75,12 +92,20 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
try {
|
try {
|
||||||
const socketUrl = await getSocketServer()
|
const socketUrl = await getSocketServer()
|
||||||
|
|
||||||
|
const isHttps = socketUrl.startsWith("https")
|
||||||
|
|
||||||
s = io(socketUrl, {
|
s = io(socketUrl, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket", "polling"],
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
|
withCredentials: false,
|
||||||
|
forceNew: true,
|
||||||
|
secure: isHttps,
|
||||||
auth: {
|
auth: {
|
||||||
clientId,
|
clientId,
|
||||||
},
|
},
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
timeout: 12000,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSocket(s)
|
setSocket(s)
|
||||||
@@ -94,7 +119,10 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.on("connect_error", (err) => {
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize socket:", error)
|
console.error("Failed to initialize socket:", error)
|
||||||
|
|||||||
Reference in New Issue
Block a user