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:
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
|
||||
Reference in New Issue
Block a user