"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 = { 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(null) const [draft, setDraft] = useState(null) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(false) const [mediaLibrary, setMediaLibrary] = useState([]) const [uploading, setUploading] = useState>({}) const [deleting, setDeleting] = useState>({}) const [refreshingLibrary, setRefreshingLibrary] = useState(false) const [probing, setProbing] = useState>({}) 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, ) => { 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((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 (
{selectedId && ( )}
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) => { const libraryEntry = getLibraryEntry(question.media) const mediaFileName = getMediaFileName(question.media) const isUploading = uploading[qIndex] const isDeleting = deleting[qIndex] return (
Question {qIndex + 1}
Media upload {isUploading ? "Uploading..." : probing[qIndex] ? "Probing..." : refreshingLibrary ? "Refreshing..." : mediaFileName ? "Stored" : "Not saved"}
{ const file = e.target.files?.[0] if (file) { handleMediaUpload(qIndex, file) e.target.value = "" } }} />

Files are stored locally and served from /media. Pick a type first.

{question.media && (
{mediaFileName || question.media.url || "No file yet"} {libraryEntry && ( {formatBytes(libraryEntry.size)} )}
{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"}
)} {question.media?.type !== "image" && question.media?.url && (
Probes audio/video duration and bumps cooldown/answer time if needed.
)}
Answers
{question.answers.map((answer, aIndex) => (
{ 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 }) }} /> updateAnswer(qIndex, aIndex, e.target.value) } placeholder={`Answer ${aIndex + 1}`} />
))}
) })}
)}
) } export default QuizEditor