mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
Compare commits
11 Commits
14ea9c75cd
...
ce89a023c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce89a023c8 | ||
|
|
fd3047ec04 | ||
|
|
8403c6a7c3 | ||
|
|
650e8f2366 | ||
|
|
a10cea357b | ||
|
|
eefb5b01f1 | ||
|
|
e5fd5d52f0 | ||
|
|
df615dc720 | ||
|
|
1988eca947 | ||
|
|
59ee57d995 | ||
|
|
87e9864290 |
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"media": {
|
||||
"type": "audio",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3"
|
||||
"url": "https://file-examples.com/storage/fee95f49ad692e9489b0fab/2017/11/file_example_WAV_1MG.wav"
|
||||
},
|
||||
"solution": 1,
|
||||
"cooldown": 5,
|
||||
@@ -32,22 +32,6 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
|
||||
import { GameUpdateQuestion, Player, QuizzWithId } from "."
|
||||
import { GameUpdateQuestion, Player, Quizz, QuizzWithId } from "."
|
||||
import { Status, StatusDataMap } from "./status"
|
||||
|
||||
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
|
||||
@@ -61,6 +61,8 @@ export interface ServerToClientEvents {
|
||||
"manager:removePlayer": (_playerId: string) => void
|
||||
"manager:errorMessage": (_message: string) => void
|
||||
"manager:playerKicked": (_playerId: string) => void
|
||||
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
@@ -73,6 +75,8 @@ export interface ClientToServerEvents {
|
||||
"manager:abortQuiz": (_message: MessageGameId) => void
|
||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||
"manager:showLeaderboard": (_message: MessageGameId) => void
|
||||
"manager:getQuizz": (_quizzId: string) => void
|
||||
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
||||
|
||||
// Player actions
|
||||
"player:join": (_inviteCode: string) => void
|
||||
|
||||
@@ -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
|
||||
|
||||
30
packages/web/src/app/api/media/[file]/route.ts
Normal file
30
packages/web/src/app/api/media/[file]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { deleteMediaFile } from "@rahoot/web/server/media"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ file: string }> },
|
||||
) {
|
||||
try {
|
||||
const params = await context.params
|
||||
const fileParam = params.file
|
||||
|
||||
if (!fileParam) {
|
||||
return NextResponse.json({ error: "Missing file parameter" }, { status: 400 })
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(fileParam)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
39
packages/web/src/app/api/media/route.ts
Normal file
39
packages/web/src/app/api/media/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
79
packages/web/src/app/media/[file]/route.ts
Normal file
79
packages/web/src/app/media/[file]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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 { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ file: string }> },
|
||||
) {
|
||||
const params = await context.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 stat = await fsp.stat(filePath)
|
||||
const fileSize = stat.size
|
||||
const mime = mimeForStoredFile(safeName)
|
||||
const range = _request.headers.get("range")
|
||||
|
||||
// Basic range support improves Safari/iOS playback
|
||||
if (range) {
|
||||
const bytesPrefix = "bytes="
|
||||
if (!range.startsWith(bytesPrefix)) {
|
||||
return new NextResponse(null, { status: 416 })
|
||||
}
|
||||
|
||||
const [rawStart, rawEnd] = range.replace(bytesPrefix, "").split("-")
|
||||
const start = Number(rawStart)
|
||||
const end = rawEnd ? Number(rawEnd) : fileSize - 1
|
||||
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start > end) {
|
||||
return new NextResponse(null, { status: 416 })
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1
|
||||
const stream = fs.createReadStream(filePath, { start, end })
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": chunkSize.toString(),
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const buffer = await fsp.readFile(filePath)
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": fileSize.toString(),
|
||||
"Accept-Ranges": "bytes",
|
||||
"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 })
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { QuestionMedia } from "@rahoot/common/types/game"
|
||||
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<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
|
||||
media?: QuestionMediaType
|
||||
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
|
||||
}
|
||||
@@ -211,60 +58,9 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default QuestionMedia
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
|
||||
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 { 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<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()
|
||||
@@ -37,6 +61,10 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
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)
|
||||
|
||||
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,164 +418,240 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
</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>
|
||||
{draft.questions.map((question, qIndex) => {
|
||||
const libraryEntry = getLibraryEntry(question.media)
|
||||
const mediaFileName = getMediaFileName(question.media)
|
||||
const isUploading = uploading[qIndex]
|
||||
const isDeleting = deleting[qIndex]
|
||||
|
||||
<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",
|
||||
)}
|
||||
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}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${qIndex}`}
|
||||
checked={question.solution === aIndex}
|
||||
onChange={() =>
|
||||
updateQuestion(qIndex, { solution: aIndex })
|
||||
}
|
||||
/>
|
||||
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
|
||||
className="flex-1"
|
||||
value={answer}
|
||||
type="number"
|
||||
value={question.cooldown}
|
||||
onChange={(e) =>
|
||||
updateAnswer(qIndex, aIndex, e.target.value)
|
||||
updateQuestion(qIndex, {
|
||||
cooldown: Number(e.target.value || 0),
|
||||
})
|
||||
}
|
||||
placeholder={`Answer ${aIndex + 1}`}
|
||||
min={0}
|
||||
/>
|
||||
<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>
|
||||
</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..."
|
||||
: 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>
|
||||
|
||||
<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",
|
||||
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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button className="bg-blue-600" onClick={addQuestion}>
|
||||
|
||||
241
packages/web/src/server/media.ts
Normal file
241
packages/web/src/server/media.ts
Normal file
@@ -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<string, StoredMedia["usedBy"]>()
|
||||
|
||||
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<StoredMedia[]> => {
|
||||
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<StoredMedia> => {
|
||||
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)
|
||||
Reference in New Issue
Block a user