diff --git a/packages/common/src/types/game/index.ts b/packages/common/src/types/game/index.ts index d1e3547..7966ab1 100644 --- a/packages/common/src/types/game/index.ts +++ b/packages/common/src/types/game/index.ts @@ -26,10 +26,9 @@ export type Quizz = { } export type QuestionMedia = - | { type: "image"; url: string } - | { type: "audio"; url: string } - | { type: "video"; url: string } - | { type: "youtube"; url: string } + | { type: "image"; url: string; fileName?: string } + | { type: "audio"; url: string; fileName?: string } + | { type: "video"; url: string; fileName?: string } export type QuizzWithId = Quizz & { id: string } diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts index f515fbb..cc25d97 100644 --- a/packages/socket/src/services/config.ts +++ b/packages/socket/src/services/config.ts @@ -16,6 +16,8 @@ const getPath = (path: string = "") => ? resolve(inContainerPath, path) : resolve(process.cwd(), "../../config", path) +export const getConfigPath = (path: string = "") => getPath(path) + class Config { static ensureBaseFolders() { const isConfigFolderExists = fs.existsSync(getPath()) @@ -29,6 +31,12 @@ class Config { if (!isQuizzExists) { fs.mkdirSync(getPath("quizz")) } + + const isMediaExists = fs.existsSync(getPath("media")) + + if (!isMediaExists) { + fs.mkdirSync(getPath("media")) + } } static init() { @@ -109,19 +117,8 @@ class Config { "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", + type: "video", + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4", }, solution: 2, cooldown: 5, @@ -216,6 +213,12 @@ class Config { return this.getQuizz(finalId) } + + static getMediaPath(fileName: string = "") { + this.ensureBaseFolders() + + return getPath(fileName ? `media/${fileName}` : "media") + } } export default Config diff --git a/packages/web/src/app/api/media/[file]/route.ts b/packages/web/src/app/api/media/[file]/route.ts new file mode 100644 index 0000000..3fd8fca --- /dev/null +++ b/packages/web/src/app/api/media/[file]/route.ts @@ -0,0 +1,24 @@ +import { deleteMediaFile } from "@rahoot/web/server/media" +import { NextResponse } from "next/server" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +type Params = { + params: { file: string } +} + +export async function DELETE(_request: Request, { params }: Params) { + try { + const decoded = decodeURIComponent(params.file) + await deleteMediaFile(decoded) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete media", error) + const message = error instanceof Error ? error.message : "Failed to delete file" + + const status = message.includes("not found") ? 404 : 400 + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/packages/web/src/app/api/media/route.ts b/packages/web/src/app/api/media/route.ts new file mode 100644 index 0000000..8759f5a --- /dev/null +++ b/packages/web/src/app/api/media/route.ts @@ -0,0 +1,39 @@ +import { listStoredMedia, storeMediaFile } from "@rahoot/web/server/media" +import { NextResponse } from "next/server" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET() { + try { + const media = await listStoredMedia() + + return NextResponse.json({ media }) + } catch (error) { + console.error("Failed to list media", error) + return NextResponse.json( + { error: "Unable to list uploaded media" }, + { status: 500 }, + ) + } +} + +export async function POST(request: Request) { + const formData = await request.formData() + const file = formData.get("file") + + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file received" }, { status: 400 }) + } + + try { + const media = await storeMediaFile(file) + + return NextResponse.json({ media }) + } catch (error) { + console.error("Failed to store media", error) + const message = error instanceof Error ? error.message : "Failed to upload file" + + return NextResponse.json({ error: message }, { status: 400 }) + } +} diff --git a/packages/web/src/app/media/[file]/route.ts b/packages/web/src/app/media/[file]/route.ts new file mode 100644 index 0000000..a18800a --- /dev/null +++ b/packages/web/src/app/media/[file]/route.ts @@ -0,0 +1,43 @@ +import Config from "@rahoot/socket/services/config" +import { mimeForStoredFile } from "@rahoot/web/server/media" +import fs from "fs" +import { promises as fsp } from "fs" +import path from "path" +import { NextResponse } from "next/server" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +type Params = { + params: { file: string } +} + +export async function GET(_request: Request, { params }: Params) { + const safeName = path.basename(params.file) + + if (safeName !== params.file) { + return NextResponse.json({ error: "Invalid file name" }, { status: 400 }) + } + + const filePath = Config.getMediaPath(safeName) + + if (!fs.existsSync(filePath)) { + return NextResponse.json({ error: "File not found" }, { status: 404 }) + } + + try { + const buffer = await fsp.readFile(filePath) + const mime = mimeForStoredFile(safeName) + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": mime, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }) + } catch (error) { + console.error("Failed to read media file", error) + return NextResponse.json({ error: "Unable to read file" }, { status: 500 }) + } +} diff --git a/packages/web/src/components/game/QuestionMedia.tsx b/packages/web/src/components/game/QuestionMedia.tsx index 441bebb..7b7b04e 100644 --- a/packages/web/src/components/game/QuestionMedia.tsx +++ b/packages/web/src/components/game/QuestionMedia.tsx @@ -2,86 +2,6 @@ import type { QuestionMedia as QuestionMediaType } 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 -} - -type YoutubeStateChangeEvent = { - data: number -} - -let youtubeApiPromise: Promise | null = null - -const loadYoutubeApi = () => { - if (typeof window === "undefined") { - return Promise.resolve(null) - } - - const existingApi = (window as any).YT as YoutubeAPI | undefined - - if (existingApi && existingApi.Player) { - return Promise.resolve(existingApi) - } - - if (!youtubeApiPromise) { - youtubeApiPromise = new Promise((resolve) => { - const tag = document.createElement("script") - tag.src = "https://www.youtube.com/iframe_api" - tag.async = true - - const handleError = () => resolve(null) - tag.onerror = handleError - - const existing = document.querySelector( - 'script[src="https://www.youtube.com/iframe_api"]', - ) - if (existing) { - existing.addEventListener("error", handleError) - } - - document.head.appendChild(tag) - - const win = window as any - - const prevOnReady = win.onYouTubeIframeAPIReady - win.onYouTubeIframeAPIReady = () => { - prevOnReady?.() - resolve(win.YT as YoutubeAPI) - } - }) - } - - return youtubeApiPromise -} - -const extractYoutubeId = (url: string) => { - try { - const parsed = new URL(url) - - if (parsed.hostname.includes("youtu.be")) { - return parsed.pathname.replace("/", "") - } - - if (parsed.searchParams.get("v")) { - return parsed.searchParams.get("v") - } - - const parts = parsed.pathname.split("/") - const embedIndex = parts.indexOf("embed") - if (embedIndex !== -1 && parts[embedIndex + 1]) { - return parts[embedIndex + 1] - } - } catch (error) { - console.error("Invalid youtube url", error) - } - - return null -} type Props = { media?: QuestionMediaType @@ -90,84 +10,6 @@ type Props = { } const QuestionMedia = ({ media, alt, onPlayChange }: Props) => { - const youtubeContainerId = useMemo( - () => `yt-${Math.random().toString(36).slice(2, 10)}`, - [], - ) - const youtubePlayerRef = useRef(null) - const youtubeMounted = useRef(false) - const [youtubeReady, setYoutubeReady] = useState(false) - const [youtubePlaying, setYoutubePlaying] = useState(false) - - useEffect(() => { - if (media?.type !== "youtube") { - return - } - - youtubeMounted.current = true - const videoId = extractYoutubeId(media.url) - - if (!videoId) { - return - } - - loadYoutubeApi().then((YT) => { - if (!YT || !youtubeMounted.current) { - return - } - - youtubePlayerRef.current = new YT.Player(youtubeContainerId, { - videoId, - playerVars: { - modestbranding: 1, - rel: 0, - iv_load_policy: 3, - playsinline: 1, - controls: 0, - disablekb: 1, - fs: 0, - enablejsapi: 1, - origin: - typeof window !== "undefined" ? window.location.origin : undefined, - showinfo: 0, - }, - host: "https://www.youtube.com", - events: { - onReady: () => { - setYoutubeReady(true) - }, - onStateChange: (event: YoutubeStateChangeEvent) => { - 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 } @@ -216,60 +58,9 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => { ) - case "youtube": { - return ( -
-
-
-
-
- - -
-
-
- ) - } + default: + return null } - - return null } export default QuestionMedia diff --git a/packages/web/src/components/game/create/QuizEditor.tsx b/packages/web/src/components/game/create/QuizEditor.tsx index ce8978d..2fcc281 100644 --- a/packages/web/src/components/game/create/QuizEditor.tsx +++ b/packages/web/src/components/game/create/QuizEditor.tsx @@ -5,7 +5,7 @@ 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 { useCallback, useEffect, useMemo, useState } from "react" import toast from "react-hot-toast" type Props = { @@ -16,6 +16,20 @@ type Props = { 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: ["", ""], @@ -24,12 +38,22 @@ const blankQuestion = (): EditableQuestion => ({ time: 20, }) -const mediaTypes: QuestionMedia["type"][] = [ - "image", - "audio", - "video", - "youtube", -] +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() @@ -37,6 +61,10 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { 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) useEvent("manager:quizzLoaded", (quizz) => { setDraft(quizz) @@ -48,6 +76,7 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { setDraft(quizz) setSelectedId(quizz.id) setSaving(false) + refreshMediaLibrary() }) useEvent("manager:quizzList", (list) => { @@ -60,6 +89,31 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { 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) @@ -138,12 +192,151 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { 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 = - type === "" ? undefined : { type, url: question.media?.url || "" } - updateQuestion(qIndex, { media: 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 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 = () => { @@ -225,11 +418,17 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
- {draft.questions.map((question, qIndex) => ( -
+ {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} @@ -306,29 +505,94 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => { -
diff --git a/packages/web/src/server/media.ts b/packages/web/src/server/media.ts new file mode 100644 index 0000000..be9e016 --- /dev/null +++ b/packages/web/src/server/media.ts @@ -0,0 +1,241 @@ +import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game" +import Config from "@rahoot/socket/services/config" +import fs from "fs" +import { promises as fsp } from "fs" +import path from "path" + +export type StoredMedia = { + fileName: string + url: string + size: number + mime: string + type: QuestionMedia["type"] + usedBy: { + quizzId: string + subject: string + questionIndex: number + question: string + }[] +} + +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB + +const ensureMediaFolder = () => { + Config.ensureBaseFolders() + const folder = Config.getMediaPath() + + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder, { recursive: true }) + } + + return folder +} + +const inferMimeFromName = (fileName: string) => { + const ext = path.extname(fileName).toLowerCase() + + if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"].includes(ext)) { + return `image/${ext.replace(".", "") || "jpeg"}` + } + + if ([".mp3", ".wav", ".ogg", ".aac", ".m4a", ".flac"].includes(ext)) { + return `audio/${ext.replace(".", "") || "mpeg"}` + } + + if ([".mp4", ".webm", ".mov", ".ogv", ".mkv"].includes(ext)) { + return `video/${ext.replace(".", "") || "mp4"}` + } + + return "application/octet-stream" +} + +const inferMediaType = (mime: string): QuestionMedia["type"] | null => { + if (mime.startsWith("image/")) return "image" + if (mime.startsWith("audio/")) return "audio" + if (mime.startsWith("video/")) return "video" + return null +} + +const sanitizeFileName = (name: string) => { + const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_") + return safeName || `media-${Date.now()}` +} + +const resolveStoredFileName = (fileName: string) => { + const safeName = path.basename(fileName) + + if (safeName !== fileName) { + throw new Error("Invalid file name") + } + + return safeName +} + +const usageIndex = (quizzList: QuizzWithId[]) => { + const usage = new Map() + + const recordUsage = ( + fileName: string | null, + quizz: QuizzWithId, + questionIndex: number, + questionTitle: string, + ) => { + if (!fileName) return + + try { + const safeName = resolveStoredFileName(fileName) + const entries = usage.get(safeName) || [] + entries.push({ + quizzId: quizz.id, + subject: quizz.subject, + questionIndex, + question: questionTitle, + }) + usage.set(safeName, entries) + } catch (error) { + console.warn("Skipped invalid media reference", { fileName, error }) + } + } + + quizzList.forEach((quizz) => { + quizz.questions.forEach((question, idx) => { + const mediaFile = (() => { + if (question.media?.fileName) return question.media.fileName + if (question.media?.url?.startsWith("/media/")) { + try { + return resolveStoredFileName( + decodeURIComponent(question.media.url.split("/").pop() || ""), + ) + } catch (error) { + console.warn("Skipped invalid media url reference", { + url: question.media.url, + error, + }) + return null + } + } + return null + })() + + const imageFile = (() => { + if (!question.image?.startsWith("/media/")) return null + try { + return resolveStoredFileName( + decodeURIComponent(question.image.split("/").pop() || ""), + ) + } catch (error) { + console.warn("Skipped invalid image url reference", { + url: question.image, + error, + }) + return null + } + })() + + recordUsage(mediaFile, quizz, idx, question.question) + recordUsage(imageFile, quizz, idx, question.question) + }) + }) + + return usage +} + +export const listStoredMedia = async (): Promise => { + const folder = ensureMediaFolder() + const files = await fsp.readdir(folder) + const quizz = Config.quizz() + const usage = usageIndex(quizz) + + const entries = await Promise.all( + files.map(async (fileName) => { + const stats = await fsp.stat(path.join(folder, fileName)) + const mime = inferMimeFromName(fileName) + const type = inferMediaType(mime) || "video" + + return { + fileName, + url: `/media/${encodeURIComponent(fileName)}`, + size: stats.size, + mime, + type, + usedBy: usage.get(fileName) || [], + } + }), + ) + + // Keep a stable order for repeatable responses + return entries.sort((a, b) => a.fileName.localeCompare(b.fileName)) +} + +export const storeMediaFile = async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + if (buffer.byteLength > MAX_UPLOAD_SIZE) { + throw new Error("File is too large. Max 50MB.") + } + + const targetFolder = ensureMediaFolder() + const incomingMime = file.type || "application/octet-stream" + const mediaType = inferMediaType(incomingMime) + + if (!mediaType) { + throw new Error("Unsupported media type") + } + + const incomingName = file.name || `${mediaType}-upload` + const safeName = sanitizeFileName(incomingName) + const ext = path.extname(safeName) || `.${incomingMime.split("/")[1] || "bin"}` + const baseName = path.basename(safeName, ext) + + let finalName = `${baseName}${ext}` + let finalPath = path.join(targetFolder, finalName) + let counter = 1 + + while (fs.existsSync(finalPath)) { + finalName = `${baseName}-${counter}${ext}` + finalPath = path.join(targetFolder, finalName) + counter += 1 + } + + await fsp.writeFile(finalPath, buffer) + + const mime = incomingMime || inferMimeFromName(finalName) + + return { + fileName: finalName, + url: `/media/${encodeURIComponent(finalName)}`, + size: buffer.byteLength, + mime, + type: mediaType, + usedBy: [], + } +} + +export const deleteMediaFile = async (fileName: string) => { + const folder = ensureMediaFolder() + const safeName = resolveStoredFileName(fileName) + const filePath = path.join(folder, safeName) + + if (!fs.existsSync(filePath)) { + throw new Error("File not found") + } + + const usage = usageIndex(Config.quizz()) + const usedBy = usage.get(safeName) || [] + + if (usedBy.length > 0) { + const details = usedBy + .map( + (entry) => + `${entry.subject || entry.quizzId} (question ${entry.questionIndex + 1})`, + ) + .join(", ") + + throw new Error(`File is still used by: ${details}`) + } + + await fsp.unlink(filePath) +} + +export const mimeForStoredFile = (fileName: string) => inferMimeFromName(fileName)