mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
813 lines
27 KiB
TypeScript
813 lines
27 KiB
TypeScript
"use client"
|
|
|
|
import type { 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 { useCallback, 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]
|
|
|
|
type MediaLibraryItem = {
|
|
fileName: string
|
|
url: string
|
|
size: number
|
|
mime: string
|
|
type: QuestionMedia["type"]
|
|
usedBy: {
|
|
quizzId: string
|
|
subject: string
|
|
questionIndex: number
|
|
question: string
|
|
}[]
|
|
}
|
|
|
|
const blankQuestion = (): EditableQuestion => ({
|
|
question: "",
|
|
answers: ["", ""],
|
|
solution: [0],
|
|
cooldown: 5,
|
|
time: 20,
|
|
})
|
|
|
|
const mediaTypes: QuestionMedia["type"][] = ["image", "audio", "video"]
|
|
|
|
const acceptByType: Record<QuestionMedia["type"], string> = {
|
|
image: "image/*",
|
|
audio: "audio/*",
|
|
video: "video/*",
|
|
}
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
if (!bytes) return "0 B"
|
|
const units = ["B", "KB", "MB", "GB"]
|
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
|
const value = bytes / 1024 ** i
|
|
|
|
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
|
}
|
|
|
|
const 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)
|
|
const [mediaLibrary, setMediaLibrary] = useState<MediaLibraryItem[]>([])
|
|
const [uploading, setUploading] = useState<Record<number, boolean>>({})
|
|
const [deleting, setDeleting] = useState<Record<number, boolean>>({})
|
|
const [refreshingLibrary, setRefreshingLibrary] = useState(false)
|
|
const [probing, setProbing] = useState<Record<number, boolean>>({})
|
|
|
|
useEvent("manager:quizzLoaded", (quizz) => {
|
|
setDraft(quizz)
|
|
setLoading(false)
|
|
})
|
|
|
|
useEvent("manager:quizzSaved", (quizz) => {
|
|
toast.success("Quiz saved")
|
|
setDraft(quizz)
|
|
setSelectedId(quizz.id)
|
|
setSaving(false)
|
|
refreshMediaLibrary()
|
|
})
|
|
|
|
useEvent("manager:quizzDeleted", (id) => {
|
|
toast.success("Quiz deleted")
|
|
if (selectedId === id) {
|
|
setSelectedId(null)
|
|
setDraft(null)
|
|
}
|
|
refreshMediaLibrary()
|
|
})
|
|
|
|
useEvent("manager:quizzList", (list) => {
|
|
onListUpdate(list)
|
|
})
|
|
|
|
useEvent("manager:errorMessage", (message) => {
|
|
toast.error(message)
|
|
setSaving(false)
|
|
setLoading(false)
|
|
})
|
|
|
|
const refreshMediaLibrary = useCallback(async () => {
|
|
setRefreshingLibrary(true)
|
|
try {
|
|
const res = await fetch("/api/media", { cache: "no-store" })
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "Failed to load media library")
|
|
}
|
|
|
|
setMediaLibrary(data.media || [])
|
|
} catch (error) {
|
|
console.error("Failed to fetch media library", error)
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to load media library",
|
|
)
|
|
} finally {
|
|
setRefreshingLibrary(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
refreshMediaLibrary()
|
|
}, [refreshMediaLibrary])
|
|
|
|
const handleLoad = (id: string) => {
|
|
setSelectedId(id)
|
|
setLoading(true)
|
|
socket?.emit("manager:getQuizz", id)
|
|
}
|
|
|
|
const handleNew = () => {
|
|
setSelectedId(null)
|
|
setDraft({
|
|
id: "",
|
|
subject: "",
|
|
questions: [blankQuestion()],
|
|
})
|
|
}
|
|
|
|
const handleDeleteQuizz = () => {
|
|
if (!selectedId) return
|
|
if (!window.confirm("Delete this quiz?")) return
|
|
setSaving(true)
|
|
socket?.emit("manager:deleteQuizz", { id: selectedId })
|
|
}
|
|
|
|
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)
|
|
const currentSolution = Array.isArray(nextQuestions[qIndex].solution)
|
|
? nextQuestions[qIndex].solution
|
|
: [nextQuestions[qIndex].solution]
|
|
const adjusted = currentSolution
|
|
.filter((idx) => idx !== aIndex)
|
|
.map((idx) => (idx > aIndex ? idx - 1 : idx))
|
|
const nextSolution =
|
|
adjusted.length > 0 ? adjusted : [Math.max(0, 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 setQuestionMedia = (qIndex: number, media?: QuestionMedia) => {
|
|
if (!draft) return
|
|
updateQuestion(qIndex, {
|
|
media,
|
|
image: media?.type === "image" ? media.url : undefined,
|
|
})
|
|
}
|
|
|
|
const getMediaFileName = (media?: QuestionMedia | null) => {
|
|
if (!media) return null
|
|
if (media.fileName) return media.fileName
|
|
if (media.url?.startsWith("/media/")) {
|
|
return decodeURIComponent(media.url.split("/").pop() || "")
|
|
}
|
|
return null
|
|
}
|
|
|
|
const getLibraryEntry = (media?: QuestionMedia | null) => {
|
|
const fileName = getMediaFileName(media)
|
|
if (!fileName) return null
|
|
|
|
return mediaLibrary.find((item) => item.fileName === fileName) || null
|
|
}
|
|
|
|
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
|
|
if (!draft) return
|
|
const question = draft.questions[qIndex]
|
|
|
|
if (type === "") {
|
|
setQuestionMedia(qIndex, undefined)
|
|
return
|
|
}
|
|
|
|
const nextMedia =
|
|
question.media?.type === type
|
|
? { ...question.media, type }
|
|
: { type, url: "" }
|
|
|
|
setQuestionMedia(qIndex, nextMedia)
|
|
}
|
|
|
|
const handleMediaUrlChange = (qIndex: number, url: string) => {
|
|
if (!draft) return
|
|
const question = draft.questions[qIndex]
|
|
|
|
if (!question.media?.type) {
|
|
toast.error("Select a media type before setting a URL")
|
|
return
|
|
}
|
|
|
|
if (!url) {
|
|
setQuestionMedia(qIndex, undefined)
|
|
return
|
|
}
|
|
|
|
const nextMedia: QuestionMedia = {
|
|
type: question.media.type,
|
|
url,
|
|
}
|
|
|
|
if (question.media.fileName && url.includes(question.media.fileName)) {
|
|
nextMedia.fileName = question.media.fileName
|
|
}
|
|
|
|
setQuestionMedia(qIndex, nextMedia)
|
|
}
|
|
|
|
const clearQuestionMedia = (qIndex: number) => {
|
|
setQuestionMedia(qIndex, undefined)
|
|
}
|
|
|
|
const probeMediaDuration = async (url: string, type: QuestionMedia["type"]) => {
|
|
if (!url || (type !== "audio" && type !== "video")) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const el = document.createElement(type)
|
|
el.crossOrigin = "anonymous"
|
|
el.preload = "metadata"
|
|
el.src = url
|
|
el.load()
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const cleanup = () => {
|
|
el.onloadedmetadata = null
|
|
el.onloadeddata = null
|
|
el.oncanplaythrough = null
|
|
el.onerror = null
|
|
}
|
|
const done = () => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
el.onloadedmetadata = done
|
|
el.onloadeddata = done
|
|
el.oncanplaythrough = done
|
|
el.onerror = () => {
|
|
cleanup()
|
|
reject(new Error("Failed to load media metadata"))
|
|
}
|
|
// safety timeout
|
|
setTimeout(() => {
|
|
cleanup()
|
|
reject(new Error("Timed out loading media metadata"))
|
|
}, 5000)
|
|
})
|
|
|
|
const duration = el.duration
|
|
return Number.isFinite(duration) && duration > 0 ? duration : null
|
|
} catch (error) {
|
|
console.warn("Failed to probe media duration", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
const adjustTimingWithMedia = async (
|
|
qIndex: number,
|
|
media: QuestionMedia | undefined,
|
|
) => {
|
|
if (!draft || !media?.url || !media.type || media.type === "image") {
|
|
return
|
|
}
|
|
|
|
setProbing((prev) => ({ ...prev, [qIndex]: true }))
|
|
|
|
try {
|
|
const duration = await probeMediaDuration(media.url, media.type)
|
|
if (!duration || !draft) {
|
|
return
|
|
}
|
|
|
|
const rounded = Math.ceil(duration)
|
|
const buffer = 3
|
|
const minCooldown = rounded
|
|
const minAnswer = rounded + buffer
|
|
const question = draft.questions[qIndex]
|
|
|
|
const nextCooldown = Math.max(question.cooldown, minCooldown)
|
|
const nextTime = Math.max(question.time, minAnswer)
|
|
|
|
if (nextCooldown !== question.cooldown || nextTime !== question.time) {
|
|
updateQuestion(qIndex, {
|
|
cooldown: nextCooldown,
|
|
time: nextTime,
|
|
})
|
|
toast.success(
|
|
`Adjusted timing to media length (~${rounded}s, answers ${nextTime}s)`,
|
|
{ id: `timing-${qIndex}` },
|
|
)
|
|
}
|
|
} finally {
|
|
setProbing((prev) => ({ ...prev, [qIndex]: false }))
|
|
}
|
|
}
|
|
|
|
const handleMediaUpload = async (qIndex: number, file: File) => {
|
|
if (!draft) return
|
|
const question = draft.questions[qIndex]
|
|
|
|
if (!question.media?.type) {
|
|
toast.error("Select a media type before uploading")
|
|
return
|
|
}
|
|
|
|
setUploading((prev) => ({ ...prev, [qIndex]: true }))
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
|
|
const res = await fetch("/api/media", {
|
|
method: "POST",
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "Failed to upload media")
|
|
}
|
|
|
|
const uploaded = data.media as MediaLibraryItem
|
|
const type = uploaded.type
|
|
|
|
setQuestionMedia(qIndex, {
|
|
type,
|
|
url: uploaded.url,
|
|
fileName: uploaded.fileName,
|
|
})
|
|
toast.success("Media uploaded")
|
|
refreshMediaLibrary()
|
|
} catch (error) {
|
|
console.error("Upload failed", error)
|
|
toast.error(error instanceof Error ? error.message : "Upload failed")
|
|
} finally {
|
|
setUploading((prev) => ({ ...prev, [qIndex]: false }))
|
|
}
|
|
}
|
|
|
|
const handleDeleteMediaFile = async (qIndex: number) => {
|
|
if (!draft) return
|
|
const question = draft.questions[qIndex]
|
|
const fileName = getMediaFileName(question.media)
|
|
|
|
if (!fileName) {
|
|
toast.error("No stored file to delete")
|
|
return
|
|
}
|
|
|
|
setDeleting((prev) => ({ ...prev, [qIndex]: true }))
|
|
|
|
try {
|
|
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
|
|
method: "DELETE",
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "Failed to delete file")
|
|
}
|
|
|
|
toast.success("File deleted")
|
|
clearQuestionMedia(qIndex)
|
|
refreshMediaLibrary()
|
|
} catch (error) {
|
|
console.error("Failed to delete file", error)
|
|
toast.error(error instanceof Error ? error.message : "Failed to delete file")
|
|
} finally {
|
|
setDeleting((prev) => ({ ...prev, [qIndex]: false }))
|
|
}
|
|
}
|
|
|
|
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>
|
|
{selectedId && (
|
|
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
|
Delete 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) => {
|
|
const libraryEntry = getLibraryEntry(question.media)
|
|
const mediaFileName = getMediaFileName(question.media)
|
|
const isUploading = uploading[qIndex]
|
|
const isDeleting = deleting[qIndex]
|
|
|
|
return (
|
|
<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>
|
|
|
|
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
|
|
<div className="flex items-center justify-between text-sm font-semibold text-gray-600">
|
|
<span>Media upload</span>
|
|
<span className="text-xs text-gray-500">
|
|
{isUploading
|
|
? "Uploading..."
|
|
: probing[qIndex]
|
|
? "Probing..."
|
|
: refreshingLibrary
|
|
? "Refreshing..."
|
|
: mediaFileName
|
|
? "Stored"
|
|
: "Not saved"}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept={
|
|
question.media?.type ? acceptByType[question.media.type] : undefined
|
|
}
|
|
disabled={!question.media?.type || isUploading}
|
|
className="rounded-sm border border-dashed border-gray-300 p-2 text-sm"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
handleMediaUpload(qIndex, file)
|
|
e.target.value = ""
|
|
}
|
|
}}
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
Files are stored locally and served from /media. Pick a type first.
|
|
</p>
|
|
|
|
{question.media && (
|
|
<div className="rounded-md border border-gray-200 bg-gray-50 p-2">
|
|
<div className="flex items-center justify-between text-sm font-semibold text-gray-700">
|
|
<span>
|
|
{mediaFileName || question.media.url || "No file yet"}
|
|
</span>
|
|
{libraryEntry && (
|
|
<span className="text-xs text-gray-500">
|
|
{formatBytes(libraryEntry.size)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{libraryEntry
|
|
? `Used in ${libraryEntry.usedBy.length} question${
|
|
libraryEntry.usedBy.length === 1 ? "" : "s"
|
|
}`
|
|
: question.media.url
|
|
? "External media URL"
|
|
: "Upload a file or paste a URL"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-xs font-semibold text-gray-600">
|
|
Or paste an external URL
|
|
</span>
|
|
<Input
|
|
value={question.media?.url || question.image || ""}
|
|
onChange={(e) => handleMediaUrlChange(qIndex, e.target.value)}
|
|
placeholder="https://..."
|
|
disabled={!question.media?.type}
|
|
/>
|
|
<span className="text-xs text-gray-500">
|
|
Tip: set answer time longer than the clip duration.
|
|
</span>
|
|
</label>
|
|
|
|
{question.media?.type !== "image" && question.media?.url && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
className="bg-gray-800"
|
|
onClick={() => adjustTimingWithMedia(qIndex, question.media)}
|
|
disabled={probing[qIndex]}
|
|
>
|
|
{probing[qIndex] ? "Probing..." : "Set timing from media"}
|
|
</Button>
|
|
<span className="text-xs text-gray-500">
|
|
Probes audio/video duration and bumps cooldown/answer time if needed.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
className="bg-gray-700"
|
|
onClick={() => clearQuestionMedia(qIndex)}
|
|
disabled={!question.media}
|
|
>
|
|
Clear from question
|
|
</Button>
|
|
<Button
|
|
className="bg-red-500"
|
|
onClick={() => handleDeleteMediaFile(qIndex)}
|
|
disabled={!mediaFileName || isDeleting}
|
|
>
|
|
{isDeleting ? "Deleting..." : "Delete file"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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",
|
|
(Array.isArray(question.solution)
|
|
? question.solution.includes(aIndex)
|
|
: question.solution === aIndex)
|
|
? "border-green-500"
|
|
: "border-gray-200",
|
|
)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name={`solution-${qIndex}-${aIndex}`}
|
|
checked={
|
|
Array.isArray(question.solution)
|
|
? question.solution.includes(aIndex)
|
|
: question.solution === aIndex
|
|
}
|
|
onChange={(e) => {
|
|
const current = Array.isArray(question.solution)
|
|
? question.solution
|
|
: [question.solution]
|
|
let next = current
|
|
if (e.target.checked) {
|
|
next = Array.from(new Set([...current, aIndex])).sort(
|
|
(a, b) => a - b,
|
|
)
|
|
} else {
|
|
next = current.filter((idx) => idx !== aIndex)
|
|
}
|
|
updateQuestion(qIndex, { solution: next })
|
|
}}
|
|
/>
|
|
<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
|