Initial clean state

This commit is contained in:
RandyJC
2025-12-09 08:55:01 +01:00
commit 497dd2ea4c
115 changed files with 12391 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
"use client"
import logo from "@rahoot/web/assets/logo.svg"
import Loader from "@rahoot/web/components/Loader"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => {
const { isConnected, connect } = useSocket()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
if (!isConnected) {
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<Loader className="h-23" />
<h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
Loading...
</h2>
</section>
)
}
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
{children}
</section>
)
}
export default AuthLayout

View File

@@ -0,0 +1,82 @@
"use client"
import { QuizzWithId } from "@rahoot/common/types/game"
import { STATUS } from "@rahoot/common/types/game/status"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager"
import { useRouter } from "next/navigation"
import { useState } from "react"
const Manager = () => {
const { setGameId, setStatus } = useManagerStore()
const router = useRouter()
const { socket } = useSocket()
const [isAuth, setIsAuth] = useState(false)
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
const [showEditor, setShowEditor] = useState(false)
const [showMedia, setShowMedia] = useState(false)
useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true)
setQuizzList(quizzList)
})
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
setGameId(gameId)
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
router.push(`/game/manager/${gameId}`)
})
const handleAuth = (password: string) => {
socket?.emit("manager:auth", password)
}
const handleCreate = (quizzId: string) => {
socket?.emit("game:create", quizzId)
}
if (!isAuth) {
return <ManagerPassword onSubmit={handleAuth} />
}
if (showEditor) {
return (
<QuizEditor
quizzList={quizzList}
onBack={() => setShowEditor(false)}
onListUpdate={setQuizzList}
/>
)
}
if (showMedia) {
return (
<div className="flex w-full flex-col gap-4">
<div className="flex gap-2">
<button
onClick={() => setShowMedia(false)}
className="rounded-md bg-gray-700 px-3 py-2 text-white"
>
Back
</button>
</div>
<MediaLibrary />
</div>
)
}
return (
<SelectQuizz
quizzList={quizzList}
onSelect={handleCreate}
onManage={() => setShowEditor(true)}
onMedia={() => setShowMedia(true)}
/>
)
}
export default Manager

View File

@@ -0,0 +1,46 @@
"use client"
import Room from "@rahoot/web/components/game/join/Room"
import Username from "@rahoot/web/components/game/join/Username"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import toast from "react-hot-toast"
const Home = () => {
const { isConnected, connect, socket } = useSocket()
const { player } = usePlayerStore()
const router = useRouter()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEffect(() => {
if (!isConnected) return
try {
const storedGameId = localStorage.getItem("last_game_id")
if (storedGameId) {
socket?.emit("player:reconnect", { gameId: storedGameId })
router.replace(`/game/${storedGameId}`)
}
} catch {
// ignore
}
}, [isConnected, socket, router])
useEvent("game:errorMessage", (message) => {
toast.error(message)
})
if (player) {
return <Username />
}
return <Room />
}
export default Home

View 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 })
}
}

View 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 })
}
}

View File

@@ -0,0 +1,126 @@
"use client"
import { STATUS } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Result from "@rahoot/web/components/game/states/Result"
import Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
import { useParams, useRouter } from "next/navigation"
import { useEffect } from "react"
import toast from "react-hot-toast"
const Game = () => {
const router = useRouter()
const { socket } = useSocket()
const { gameId: gameIdParam }: { gameId?: string } = useParams()
const { status, player, setPlayer, setGameId, setStatus, reset } =
usePlayerStore()
const { setQuestionStates } = useQuestionStore()
useEvent("connect", () => {
if (gameIdParam) {
socket?.emit("player:reconnect", { gameId: gameIdParam })
}
})
useEvent(
"player:successReconnect",
({ gameId, status, player, currentQuestion }) => {
setGameId(gameId)
setStatus(status.name, status.data)
setPlayer(player)
setQuestionStates(currentQuestion)
try {
localStorage.setItem("last_game_id", gameId)
if (player?.username) {
localStorage.setItem("last_username", player.username)
}
} catch {}
},
)
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS) {
setStatus(name, data)
}
})
useEvent("game:reset", (message) => {
router.replace("/")
reset()
setQuestionStates(null)
try {
localStorage.removeItem("last_game_id")
localStorage.removeItem("last_username")
localStorage.removeItem("last_points")
} catch {}
toast.error(message)
})
// Hydrate username/points for footer immediately after refresh
useEffect(() => {
if (player?.username) return
try {
const name = localStorage.getItem("last_username")
const ptsRaw = localStorage.getItem("last_points")
const pts = ptsRaw ? Number(ptsRaw) : undefined
if (name || typeof pts === "number") {
setPlayer({
username: name || undefined,
points: pts,
})
}
} catch {
// ignore
}
}, [player?.username, setPlayer])
if (!gameIdParam) {
return null
}
let component = null
switch (status?.name) {
case STATUS.WAIT:
component = <Wait data={status.data} />
break
case STATUS.SHOW_START:
component = <Start data={status.data} />
break
case STATUS.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case STATUS.SHOW_QUESTION:
component = <Question data={status.data} />
break
case STATUS.SHOW_RESULT:
component = <Result data={status.data} />
break
case STATUS.SELECT_ANSWER:
component = <Answers data={status.data} />
break
}
return <GameWrapper statusName={status?.name}>{component}</GameWrapper>
}
export default Game

View File

@@ -0,0 +1,17 @@
"use client"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import { PropsWithChildren, useEffect } from "react"
const GameLayout = ({ children }: PropsWithChildren) => {
const { isConnected, connect } = useSocket()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
return children
}
export default GameLayout

View File

@@ -0,0 +1,185 @@
"use client"
import { STATUS } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
import Podium from "@rahoot/web/components/game/states/Podium"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Responses from "@rahoot/web/components/game/states/Responses"
import Room from "@rahoot/web/components/game/states/Room"
import Start from "@rahoot/web/components/game/states/Start"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
import { useParams, useRouter } from "next/navigation"
import toast from "react-hot-toast"
import { useState } from "react"
const ManagerGame = () => {
const router = useRouter()
const { gameId: gameIdParam }: { gameId?: string } = useParams()
const { socket } = useSocket()
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
useManagerStore()
const { setQuestionStates } = useQuestionStore()
const [cooldownPaused, setCooldownPaused] = useState(false)
const { players } = useManagerStore()
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS_MANAGER) {
setStatus(name, data)
}
})
useEvent("connect", () => {
if (gameIdParam) {
socket?.emit("manager:reconnect", { gameId: gameIdParam })
}
})
useEvent(
"manager:successReconnect",
({ gameId, status, players, currentQuestion }) => {
setGameId(gameId)
setStatus(status.name, status.data)
setPlayers(players)
setQuestionStates(currentQuestion)
},
)
useEvent("game:reset", (message) => {
router.replace("/manager")
reset()
setQuestionStates(null)
toast.error(message)
})
useEvent("manager:newPlayer", (player) => {
setPlayers((prev) => [...prev.filter((p) => p.id !== player.id), player])
})
useEvent("manager:removePlayer", (playerId) => {
setPlayers((prev) => prev.filter((p) => p.id !== playerId))
})
useEvent("manager:players", (players) => {
setPlayers(players)
})
useEvent("game:cooldownPause", (isPaused) => {
setCooldownPaused(isPaused)
})
const handleSkip = () => {
if (!gameId) {
return
}
switch (status?.name) {
case STATUS.SHOW_ROOM:
socket?.emit("manager:startGame", { gameId })
break
case STATUS.SHOW_QUESTION:
socket?.emit("manager:skipQuestionIntro", { gameId })
break
case STATUS.SELECT_ANSWER:
socket?.emit("manager:abortQuiz", { gameId })
break
case STATUS.SHOW_RESPONSES:
socket?.emit("manager:showLeaderboard", { gameId })
break
case STATUS.SHOW_LEADERBOARD:
socket?.emit("manager:nextQuestion", { gameId })
break
}
}
const handlePauseToggle = () => {
if (!gameId) return
if (cooldownPaused) {
socket?.emit("manager:resumeCooldown", { gameId })
} else {
socket?.emit("manager:pauseCooldown", { gameId })
}
}
const handleEndGame = () => {
if (!gameId) return
socket?.emit("manager:endGame", { gameId })
}
let component = null
switch (status?.name) {
case STATUS.SHOW_ROOM:
component = <Room data={status.data} />
break
case STATUS.SHOW_START:
component = <Start data={status.data} />
break
case STATUS.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case STATUS.SHOW_QUESTION:
component = <Question data={status.data} />
break
case STATUS.SELECT_ANSWER:
component = <Answers data={status.data} />
break
case STATUS.SHOW_RESPONSES:
component = <Responses data={status.data} />
break
case STATUS.SHOW_LEADERBOARD:
component = <Leaderboard data={status.data} />
break
case STATUS.FINISHED:
component = <Podium data={status.data} />
break
}
return (
<GameWrapper
statusName={status?.name}
onNext={handleSkip}
onPause={handlePauseToggle}
paused={cooldownPaused}
showPause={
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
}
onEnd={handleEndGame}
players={players}
manager
>
{component}
</GameWrapper>
)
}
export default ManagerGame

View File

@@ -0,0 +1,199 @@
@import "tailwindcss";
@theme {
--color-primary: #ff9900;
--color-secondary: #1a140b;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
.btn-shadow {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
}
.btn-shadow span {
display: block;
transform: translateY(-2px);
}
.btn-shadow:hover {
box-shadow: rgba(0, 0, 0, 0.25) 0px -2px inset;
}
.btn-shadow:hover span {
transform: translateY(0);
}
.btn-shadow:active {
transform: translateY(1px);
box-shadow: none;
}
.text-outline {
-webkit-text-stroke: 2px rgba(0, 0, 0, 0.25);
}
.shadow-inset {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
}
.spotlight {
position: absolute;
height: 200%;
width: 200%;
z-index: 100;
background-image: radial-gradient(
circle,
transparent 180px,
rgba(0, 0, 0, 0.6) 200px
);
opacity: 0;
left: -50%;
top: -50%;
transition: all 0.5s;
animation: spotlightAnim 2.5s ease-in;
}
@keyframes spotlightAnim {
0% {
left: -20%;
top: -20%;
}
30% {
opacity: 100;
top: -80%;
left: -80%;
}
60% {
top: -50%;
left: -20%;
}
80% {
top: -50%;
left: -50%;
}
98% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.anim-show {
animation: show 0.5s ease-out;
}
.anim-timer {
animation: timer 1s ease-out infinite;
}
.anim-quizz {
animation: quizz 0.8s linear;
transform: perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
box-shadow: 10px 10px 0 rgba(20, 24, 29, 1);
}
.anim-quizz .button {
box-shadow: rgba(0, 0, 0, 0.25) -4px -4px inset;
animation: quizzButton 0.8s ease-out;
}
.anim-balanced {
animation: balanced 0.8s linear infinite;
}
@keyframes balanced {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(-10deg) translateY(-10px);
}
50% {
transform: rotate(0deg) translateY(0px);
}
75% {
transform: rotate(10deg) translateY(-10px);
}
100% {
transform: rotate(0deg);
}
}
@keyframes show {
0% {
transform: scale(0);
}
30% {
transform: scale(0.9);
}
60% {
transform: scale(0.8);
}
80% {
transform: scale(1);
}
}
@keyframes progressBar {
from {
width: 0%;
}
to {
width: 100%;
}
}
@keyframes timer {
0% {
transform: scale(1);
}
30% {
transform: scale(1.4) rotate(-6deg);
}
60% {
transform: scale(0.8) rotate(6deg);
}
80% {
transform: scale(1);
}
}
@keyframes quizz {
0% {
transform: scale(0) perspective(1200px) rotateY(-60deg) rotateX(60deg)
translateZ(100px);
}
60% {
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
80% {
transform: scale(0.8) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
100% {
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
translateZ(100px);
}
}
@keyframes quizzButton {
0% {
transform: scale(0);
}
60% {
transform: scale(1);
}
80% {
transform: scale(0.8);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,29 @@
import Toaster from "@rahoot/web/components/Toaster"
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
import type { Metadata } from "next"
import { Montserrat } from "next/font/google"
import { PropsWithChildren } from "react"
import "./globals.css"
const montserrat = Montserrat({
variable: "--font-montserrat",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "Rahoot !",
icons: "/icon.svg",
}
const RootLayout = ({ children }: PropsWithChildren) => (
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
<body className={`${montserrat.variable} bg-secondary antialiased`}>
<SocketProvider>
<main className="text-base-[8px] flex flex-col">{children}</main>
<Toaster />
</SocketProvider>
</body>
</html>
)
export default RootLayout

View File

@@ -0,0 +1,86 @@
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 { Readable } from "node:stream"
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 < 0 ||
end >= fileSize ||
start > end
) {
return new NextResponse(null, { status: 416 })
}
const chunkSize = end - start + 1
const stream = fs.createReadStream(filePath, { start, end })
return new NextResponse(Readable.toWeb(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 stream = fs.createReadStream(filePath)
return new NextResponse(Readable.toWeb(stream) as any, {
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 })
}
}

View File

@@ -0,0 +1,10 @@
import env from "@rahoot/web/env"
import { NextResponse } from "next/server"
export function GET() {
return NextResponse.json({
url: env.SOCKET_URL,
})
}
export const dynamic = "force-dynamic"

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;display:block;" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" r="34" stroke="#fff4e4" stroke-width="13" stroke-linecap="square" fill="none">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform>
<animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="21.362830044410593 192.26547039969535;106.81415022205297 106.81415022205297;21.362830044410593 192.26547039969535" keyTimes="0;0.5;1"></animate>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 704 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,27 @@
import clsx from "clsx"
import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
type Props = PropsWithChildren &
ButtonHTMLAttributes<HTMLButtonElement> & {
icon: ElementType
}
const AnswerButton = ({
className,
icon: Icon,
children,
...otherProps
}: Props) => (
<button
className={clsx(
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left",
className,
)}
{...otherProps}
>
<Icon className="h-6 w-6" />
<span className="drop-shadow-md">{children}</span>
</button>
)
export default AnswerButton

View File

@@ -0,0 +1,18 @@
import clsx from "clsx"
import { ButtonHTMLAttributes, PropsWithChildren } from "react"
type Props = ButtonHTMLAttributes<HTMLButtonElement> & PropsWithChildren
const Button = ({ children, className, ...otherProps }: Props) => (
<button
className={clsx(
"btn-shadow bg-primary rounded-md p-2 text-lg font-semibold text-white",
className,
)}
{...otherProps}
>
<span>{children}</span>
</button>
)
export default Button

View File

@@ -0,0 +1,9 @@
import { PropsWithChildren } from "react"
const Form = ({ children }: PropsWithChildren) => (
<div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
{children}
</div>
)
export default Form

View File

@@ -0,0 +1,17 @@
import clsx from "clsx"
import React from "react"
type Props = React.InputHTMLAttributes<HTMLInputElement>
const Input = ({ className, type = "text", ...otherProps }: Props) => (
<input
type={type}
className={clsx(
"rounded-sm p-2 text-lg font-semibold outline-2 outline-gray-300",
className,
)}
{...otherProps}
/>
)
export default Input

View File

@@ -0,0 +1,12 @@
import loader from "@rahoot/web/assets/loader.svg"
import Image from "next/image"
type Props = {
className?: string
}
const Loader = ({ className }: Props) => (
<Image className={className} alt="loader" src={loader} />
)
export default Loader

View File

@@ -0,0 +1,27 @@
"use client"
import { ToastBar, Toaster as ToasterRaw } from "react-hot-toast"
const Toaster = () => (
<ToasterRaw>
{(t) => (
<ToastBar
toast={t}
style={{
...t.style,
boxShadow: "rgba(0, 0, 0, 0.25) 0px -4px inset",
fontWeight: 700,
}}
>
{({ icon, message }) => (
<>
{icon}
{message}
</>
)}
</ToastBar>
)}
</ToasterRaw>
)
export default Toaster

View File

@@ -0,0 +1,151 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import background from "@rahoot/web/assets/background.webp"
import Button from "@rahoot/web/components/Button"
import Loader from "@rahoot/web/components/Loader"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import Image from "next/image"
import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & {
statusName: Status | undefined
onNext?: () => void
onPause?: () => void
paused?: boolean
showPause?: boolean
onEnd?: () => void
players?: { id: string; username: string; connected: boolean }[]
manager?: boolean
}
const GameWrapper = ({
children,
statusName,
onNext,
onPause,
paused,
showPause,
onEnd,
players,
manager,
}: Props) => {
const { isConnected } = useSocket()
const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore()
const [isDisabled, setIsDisabled] = useState(false)
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
useEvent("game:updateQuestion", ({ current, total }) => {
setQuestionStates({
current,
total,
})
})
useEffect(() => {
setIsDisabled(false)
}, [statusName])
const handleNext = () => {
setIsDisabled(true)
onNext?.()
}
return (
<section className="relative flex min-h-screen w-full flex-col justify-between">
<div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
<Image
className="pointer-events-none h-full w-full object-cover opacity-60"
src={background}
alt="background"
/>
</div>
{!isConnected && !statusName ? (
<div className="flex h-full w-full flex-1 flex-col items-center justify-center">
<Loader />
<h1 className="text-4xl font-bold text-white">Connecting...</h1>
</div>
) : (
<>
<div className="flex w-full justify-between p-4">
{questionStates && (
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
{`${questionStates.current} / ${questionStates.total}`}
</div>
)}
{manager && next && (
<Button
className={clsx("self-end bg-white px-4 text-black!", {
"pointer-events-none": isDisabled,
})}
onClick={handleNext}
>
{next}
</Button>
)}
{manager && showPause && (
<Button
className={clsx("self-end bg-white px-4 text-black!", {
"pointer-events-none": isDisabled,
})}
onClick={onPause}
>
{paused ? "Resume" : "Pause"}
</Button>
)}
{manager && onEnd && (
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
End game
</Button>
)}
</div>
{manager && players && players.length > 0 && (
<div className="mx-4 mb-2 rounded-md bg-white/90 p-3 text-sm shadow">
<div className="mb-1 text-xs font-semibold uppercase text-gray-600">
Players ({players.length})
</div>
<div className="flex flex-wrap gap-2">
{players.map((p) => (
<span
key={p.id}
className={clsx(
"rounded border px-2 py-1 font-semibold",
p.connected
? "border-green-500 text-green-700"
: "border-gray-300 text-gray-500",
)}
>
{p.username || p.id} {p.connected ? "" : "(disc.)"}
</span>
))}
</div>
</div>
)}
{children}
{!manager && (
<div className="z-50 flex items-center justify-between bg-white px-4 py-2 text-lg font-bold text-white">
<p className="text-gray-800">{player?.username}</p>
<div className="rounded-sm bg-gray-800 px-3 py-1 text-lg">
{player?.points}
</div>
</div>
)}
</>
)}
</section>
)
}
export default GameWrapper

View File

@@ -0,0 +1,87 @@
"use client"
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
import clsx from "clsx"
import { useState } from "react"
type Props = {
media?: QuestionMediaType
alt: string
onPlayChange?: (_playing: boolean) => void
}
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
const [zoomed, setZoomed] = useState(false)
if (!media) {
return null
}
const containerClass = "mx-auto flex w-full max-w-5xl justify-center"
switch (media.type) {
case "image":
return (
<>
<div className={containerClass}>
<img
alt={alt}
src={media.url}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full cursor-zoom-in rounded-md object-contain shadow-lg"
onClick={() => setZoomed(true)}
/>
</div>
{zoomed && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={() => setZoomed(false)}
>
<img
src={media.url}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-md shadow-2xl"
/>
</div>
)}
</>
)
case "audio":
return (
<div className={clsx(containerClass, "px-4")}>
<audio
controls
crossOrigin="anonymous"
src={media.url}
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
preload="none"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
case "video":
return (
<div className={containerClass}>
<video
controls
crossOrigin="anonymous"
playsInline
src={media.url}
className="m-4 w-full max-w-5xl rounded-md shadow-lg"
preload="metadata"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
default:
return null
}
}
export default QuestionMedia

View File

@@ -0,0 +1,42 @@
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import { KeyboardEvent, useState } from "react"
import toast from "react-hot-toast"
type Props = {
onSubmit: (_password: string) => void
}
const ManagerPassword = ({ onSubmit }: Props) => {
const [password, setPassword] = useState("")
const handleSubmit = () => {
onSubmit(password)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleSubmit()
}
}
useEvent("manager:errorMessage", (message) => {
toast.error(message)
})
return (
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
)
}
export default ManagerPassword

View File

@@ -0,0 +1,147 @@
"use client"
import Button from "@rahoot/web/components/Button"
import { useEffect, useState } from "react"
type MediaItem = {
fileName: string
url: string
size: number
mime: string
type: string
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
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 MediaLibrary = () => {
const [items, setItems] = useState<MediaItem[]>([])
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState<Record<string, boolean>>({})
const load = async () => {
setLoading(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")
setItems(data.media || [])
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleDelete = async (fileName: string) => {
setDeleting((prev) => ({ ...prev, [fileName]: 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")
load()
} catch (error) {
console.error(error)
alert(error instanceof Error ? error.message : "Failed to delete")
} finally {
setDeleting((prev) => ({ ...prev, [fileName]: false }))
}
}
return (
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-800">Media library</h2>
<p className="text-sm text-gray-500">
Uploaded files with their usage. Delete is enabled only when unused.
</p>
</div>
<Button className="bg-gray-700" onClick={load} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase text-gray-500">
<th className="p-2">File</th>
<th className="p-2">Type</th>
<th className="p-2">Size</th>
<th className="p-2">Used by</th>
<th className="p-2">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.fileName} className="border-b border-gray-100">
<td className="p-2 font-semibold text-gray-800">
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="text-blue-600 underline"
>
{item.fileName}
</a>
</td>
<td className="p-2">{item.type}</td>
<td className="p-2 text-gray-600">{formatBytes(item.size)}</td>
<td className="p-2">
{item.usedBy.length === 0 ? (
<span className="text-green-700">Unused</span>
) : (
<div className="space-y-1">
{item.usedBy.map((u, idx) => (
<div key={idx} className="text-gray-700">
<span className="font-semibold">{u.subject || u.quizzId}</span>
{` Q${u.questionIndex + 1}: ${u.question}`}
</div>
))}
</div>
)}
</td>
<td className="p-2">
<Button
className="bg-red-500 px-3 py-1 text-sm"
onClick={() => handleDelete(item.fileName)}
disabled={item.usedBy.length > 0 || deleting[item.fileName]}
>
{deleting[item.fileName] ? "Deleting..." : "Delete"}
</Button>
</td>
</tr>
))}
{items.length === 0 && !loading && (
<tr>
<td className="p-3 text-sm text-gray-500" colSpan={5}>
No media uploaded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
export default MediaLibrary

View File

@@ -0,0 +1,791 @@
"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)
let nextSolution = nextQuestions[qIndex].solution
if (nextSolution >= currentAnswers.length) {
nextSolution = currentAnswers.length - 1
}
nextQuestions[qIndex] = {
...nextQuestions[qIndex],
answers: currentAnswers,
solution: nextSolution,
}
setDraft({ ...draft, questions: nextQuestions })
}
const addQuestion = () => {
if (!draft) return
setDraft({ ...draft, questions: [...draft.questions, blankQuestion()] })
}
const removeQuestion = (index: number) => {
if (!draft || draft.questions.length <= 1) return
const nextQuestions = draft.questions.filter((_, i) => i !== index)
setDraft({ ...draft, questions: nextQuestions })
}
const 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",
question.solution === aIndex
? "border-green-500"
: "border-gray-200",
)}
>
<input
type="radio"
name={`solution-${qIndex}`}
checked={question.solution === aIndex}
onChange={() =>
updateQuestion(qIndex, { solution: aIndex })
}
/>
<Input
className="flex-1"
value={answer}
onChange={(e) =>
updateAnswer(qIndex, aIndex, e.target.value)
}
placeholder={`Answer ${aIndex + 1}`}
/>
<button
className="rounded-sm px-2 py-1 text-sm font-semibold text-red-500"
onClick={() => removeAnswer(qIndex, aIndex)}
disabled={question.answers.length <= 2}
>
Remove
</button>
</div>
))}
</div>
</div>
</div>
)
})}
<div className="flex justify-center">
<Button className="bg-blue-600" onClick={addQuestion}>
Add question
</Button>
</div>
</div>
)}
</div>
)
}
export default QuizEditor

View File

@@ -0,0 +1,86 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import Button from "@rahoot/web/components/Button"
import clsx from "clsx"
import { useState } from "react"
import toast from "react-hot-toast"
type Props = {
quizzList: QuizzWithId[]
onSelect: (_id: string) => void
onManage?: () => void
onMedia?: () => void
}
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => {
if (selected === id) {
setSelected(null)
} else {
setSelected(id)
}
}
const handleSubmit = () => {
if (!selected) {
toast.error("Please select a quizz")
return
}
onSelect(selected)
}
return (
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Select a quizz</h1>
<div className="flex items-center gap-2">
{onMedia && (
<button
className="text-sm font-semibold text-gray-700 underline"
onClick={onMedia}
>
Media
</button>
)}
{onManage && (
<button
className="text-sm font-semibold text-primary underline"
onClick={onManage}
>
Manage
</button>
)}
</div>
</div>
<div className="flex flex-col items-center justify-center">
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button
key={quizz.id}
className={clsx(
"flex w-full items-center justify-between rounded-md p-3 outline outline-gray-300",
)}
onClick={handleSelect(quizz.id)}
>
{quizz.subject}
<div
className={clsx(
"h-5 w-5 rounded outline outline-offset-3 outline-gray-300",
selected === quizz.id &&
"bg-primary border-primary/80 shadow-inset",
)}
></div>
</button>
))}
</div>
</div>
<Button onClick={handleSubmit}>Submit</Button>
</div>
)
}
export default SelectQuizz

View File

@@ -0,0 +1,39 @@
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { KeyboardEvent, useState } from "react"
const Room = () => {
const { socket } = useSocket()
const { join } = usePlayerStore()
const [invitation, setInvitation] = useState("")
const handleJoin = () => {
socket?.emit("player:join", invitation)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleJoin()
}
}
useEvent("game:successRoom", (gameId) => {
join(gameId)
})
return (
<Form>
<Input
onChange={(e) => setInvitation(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="PIN Code here"
/>
<Button onClick={handleJoin}>Submit</Button>
</Form>
)
}
export default Room

View File

@@ -0,0 +1,56 @@
"use client"
import { STATUS } from "@rahoot/common/types/game/status"
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useRouter } from "next/navigation"
import { KeyboardEvent, useState } from "react"
const Username = () => {
const { socket } = useSocket()
const { gameId, login, setStatus } = usePlayerStore()
const router = useRouter()
const [username, setUsername] = useState("")
const handleLogin = () => {
if (!gameId) {
return
}
socket?.emit("player:login", { gameId, data: { username } })
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleLogin()
}
}
useEvent("game:successJoin", (gameId) => {
setStatus(STATUS.WAIT, { text: "Waiting for the players" })
login(username)
try {
localStorage.setItem("last_game_id", gameId)
localStorage.setItem("last_username", username)
} catch {}
router.replace(`/game/${gameId}`)
})
return (
<Form>
<Input
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Username here"
/>
<Button onClick={handleLogin}>Submit</Button>
</Form>
)
}
export default Username

View File

@@ -0,0 +1,141 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
SFX_ANSWERS_MUSIC,
SFX_ANSWERS_SOUND,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SELECT_ANSWER"]
}
const Answers = ({
data: { question, answers, image, media, time, totalPlayer },
}: Props) => {
const { gameId }: { gameId?: string } = useParams()
const { socket } = useSocket()
const { player } = usePlayerStore()
const [cooldown, setCooldown] = useState(time)
const [paused, setPaused] = useState(false)
const [totalAnswer, setTotalAnswer] = useState(0)
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
volume: 0.1,
})
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
SFX_ANSWERS_MUSIC,
{
volume: 0.2,
interrupt: true,
loop: true,
},
)
const handleAnswer = (answerKey: number) => () => {
if (!player) {
return
}
socket?.emit("player:selectedAnswer", {
gameId,
data: {
answerKey,
},
})
sfxPop()
}
useEffect(() => {
playMusic()
return () => {
stopMusic()
}
}, [playMusic])
useEffect(() => {
if (!answersMusic) {
return
}
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
}, [answersMusic, isMediaPlaying])
useEvent("game:cooldown", (sec) => {
setCooldown(sec)
})
useEvent("game:cooldownPause", (isPaused) => {
setPaused(isPaused)
})
useEvent("game:playerAnswer", (count) => {
setTotalAnswer(count)
sfxPop()
})
return (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
onPlayChange={(playing) => setIsMediaPlaying(playing)}
/>
</div>
<div>
<div className="mx-auto mb-4 flex w-full max-w-7xl justify-between gap-1 px-2 text-lg font-bold text-white md:text-xl">
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
<span className="translate-y-1 text-sm">Time</span>
<span>{cooldown}</span>
{paused && (
<span className="text-xs font-semibold uppercase text-amber-200">
Paused
</span>
)}
</div>
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
<span className="translate-y-1 text-sm">Answers</span>
<span>
{totalAnswer}/{totalPlayer}
</span>
</div>
</div>
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => (
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key])}
icon={ANSWERS_ICONS[key]}
onClick={handleAnswer(key)}
>
{answer}
</AnswerButton>
))}
</div>
</div>
</div>
)
}
export default Answers

View File

@@ -0,0 +1,92 @@
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import { AnimatePresence, motion, useSpring, useTransform } from "motion/react"
import { useEffect, useState } from "react"
type Props = {
data: ManagerStatusDataMap["SHOW_LEADERBOARD"]
}
const AnimatedPoints = ({ from, to }: { from: number; to: number }) => {
const spring = useSpring(from, { stiffness: 1000, damping: 30 })
const display = useTransform(spring, (value) => Math.round(value))
const [displayValue, setDisplayValue] = useState(from)
useEffect(() => {
spring.set(to)
const unsubscribe = display.on("change", (latest) => {
setDisplayValue(latest)
})
return unsubscribe
}, [to, spring, display])
return <span className="drop-shadow-md">{displayValue}</span>
}
const Leaderboard = ({ data: { oldLeaderboard, leaderboard } }: Props) => {
const [displayedLeaderboard, setDisplayedLeaderboard] =
useState(oldLeaderboard)
const [isAnimating, setIsAnimating] = useState(false)
useEffect(() => {
setDisplayedLeaderboard(oldLeaderboard)
setIsAnimating(false)
const timer = setTimeout(() => {
setIsAnimating(true)
setDisplayedLeaderboard(leaderboard)
}, 1600)
return () => {
clearTimeout(timer)
}
}, [oldLeaderboard, leaderboard])
return (
<section className="relative mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center px-2">
<h2 className="mb-6 text-5xl font-bold text-white drop-shadow-md">
Leaderboard
</h2>
<div className="flex w-full flex-col gap-2">
<AnimatePresence mode="popLayout">
{displayedLeaderboard.map(({ id, username, points }) => (
<motion.div
key={id}
layout
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 50,
transition: { duration: 0.2 },
}}
transition={{
layout: {
type: "spring",
stiffness: 350,
damping: 25,
},
}}
className="bg-primary flex w-full justify-between rounded-md p-3 text-2xl font-bold text-white"
>
<span className="drop-shadow-md">{username}</span>
{isAnimating ? (
<AnimatedPoints
from={oldLeaderboard.find((u) => u.id === id)?.points || 0}
to={leaderboard.find((u) => u.id === id)?.points || 0}
/>
) : (
<span className="drop-shadow-md">{points}</span>
)}
</motion.div>
))}
</AnimatePresence>
</div>
</section>
)
}
export default Leaderboard

View File

@@ -0,0 +1,204 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import useScreenSize from "@rahoot/web/hooks/useScreenSize"
import {
SFX_PODIUM_FIRST,
SFX_PODIUM_SECOND,
SFX_PODIUM_THREE,
SFX_SNEAR_ROOL,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useEffect, useState } from "react"
import ReactConfetti from "react-confetti"
import useSound from "use-sound"
type Props = {
data: ManagerStatusDataMap["FINISHED"]
}
const Podium = ({ data: { subject, top } }: Props) => {
const [apparition, setApparition] = useState(0)
const { width, height } = useScreenSize()
const [sfxtThree] = useSound(SFX_PODIUM_THREE, {
volume: 0.2,
})
const [sfxSecond] = useSound(SFX_PODIUM_SECOND, {
volume: 0.2,
})
const [sfxRool, { stop: sfxRoolStop }] = useSound(SFX_SNEAR_ROOL, {
volume: 0.2,
})
const [sfxFirst] = useSound(SFX_PODIUM_FIRST, {
volume: 0.2,
})
useEffect(() => {
switch (apparition) {
case 4:
sfxRoolStop()
sfxFirst()
break
case 3:
sfxRool()
break
case 2:
sfxSecond()
break
case 1:
sfxtThree()
break
}
}, [apparition, sfxFirst, sfxSecond, sfxtThree, sfxRool, sfxRoolStop])
useEffect(() => {
if (top.length < 3) {
setApparition(4)
return
}
const interval = setInterval(() => {
if (apparition > 4) {
clearInterval(interval)
return
}
setApparition((value) => value + 1)
}, 2000)
// eslint-disable-next-line consistent-return
return () => clearInterval(interval)
}, [apparition, top.length])
return (
<>
{apparition >= 4 && (
<ReactConfetti
width={width}
height={height}
className="h-full w-full"
/>
)}
{apparition >= 3 && top.length >= 3 && (
<div className="pointer-events-none absolute min-h-screen w-full overflow-hidden">
<div className="spotlight"></div>
</div>
)}
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between">
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{subject}
</h2>
<div
style={{ gridTemplateColumns: `repeat(${top.length}, 1fr)` }}
className={`grid w-full max-w-[800px] flex-1 items-end justify-center justify-self-end overflow-x-visible overflow-y-hidden`}
>
{top[1] && (
<div
className={clsx(
"z-20 flex h-[50%] w-full translate-y-full flex-col items-center justify-center gap-3 opacity-0 transition-all",
{ "translate-y-0! opacity-100": apparition >= 2 },
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{
"anim-balanced": apparition >= 4,
},
)}
>
{top[1].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-zinc-400 bg-zinc-500 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">2</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[1].points}
</p>
</div>
</div>
)}
<div
className={clsx(
"z-30 flex h-[60%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
{
"translate-y-0! opacity-100": apparition >= 3,
},
{
"md:min-w-64": top.length < 2,
},
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white opacity-0 drop-shadow-lg md:text-4xl",
{ "anim-balanced opacity-100": apparition >= 4 },
)}
>
{top[0].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-400 bg-amber-300 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">1</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[0].points}
</p>
</div>
</div>
{top[2] && (
<div
className={clsx(
"z-10 flex h-[40%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
{
"translate-y-0! opacity-100": apparition >= 1,
},
)}
>
<p
className={clsx(
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{
"anim-balanced": apparition >= 4,
},
)}
>
{top[2].username}
</p>
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-800 bg-amber-700 text-3xl font-bold text-white drop-shadow-lg">
<span className="drop-shadow-md">3</span>
</p>
<p className="text-2xl font-bold text-white drop-shadow-lg">
{top[2].points}
</p>
</div>
</div>
)}
</div>
</section>
</>
)
}
export default Podium

View File

@@ -0,0 +1,31 @@
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { ANSWERS_COLORS, ANSWERS_ICONS } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { createElement } from "react"
type Props = {
data: CommonStatusDataMap["SHOW_PREPARED"]
}
const Prepared = ({ data: { totalAnswers, questionNumber } }: Props) => (
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
<h2 className="anim-show mb-20 text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
Question #{questionNumber}
</h2>
<div className="anim-quizz grid aspect-square w-60 grid-cols-2 gap-4 rounded-2xl bg-gray-700 p-5 md:w-60">
{[...Array(totalAnswers)].map((_, key) => (
<div
key={key}
className={clsx(
"button shadow-inset flex aspect-square h-full w-full items-center justify-center rounded-2xl",
ANSWERS_COLORS[key],
)}
>
{createElement(ANSWERS_ICONS[key], { className: "h-10 md:h-14" })}
</div>
))}
</div>
</section>
)
export default Prepared

View File

@@ -0,0 +1,60 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
import { useEffect, useState } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_QUESTION"]
}
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
const [seconds, setSeconds] = useState(cooldown)
const [paused, setPaused] = useState(false)
useEffect(() => {
sfxShow()
}, [sfxShow])
useEvent("game:cooldown", (sec) => {
setSeconds(sec)
})
useEvent("game:cooldownPause", (isPaused) => {
setPaused(isPaused)
})
const percent = Math.max(0, Math.min(100, (seconds / cooldown) * 100))
return (
<section className="relative mx-auto flex h-full w-full max-w-7xl flex-1 flex-col items-center px-4">
<div className="flex flex-1 flex-col items-center justify-center gap-5">
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
/>
</div>
<div className="mb-20 h-4 w-full max-w-4xl self-start overflow-hidden rounded-full bg-white/30">
<div
className="h-full bg-primary transition-[width]"
style={{ width: `${percent}%` }}
/>
</div>
{paused && (
<div className="absolute bottom-6 right-6 rounded-md bg-black/60 px-3 py-1 text-sm font-semibold text-white">
Paused
</div>
)}
</section>
)
}
export default Question

View File

@@ -0,0 +1,123 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
SFX_ANSWERS_MUSIC,
SFX_RESULTS_SOUND,
} from "@rahoot/web/utils/constants"
import { calculatePercentages } from "@rahoot/web/utils/score"
import clsx from "clsx"
import { useEffect, useState } from "react"
import useSound from "use-sound"
type Props = {
data: ManagerStatusDataMap["SHOW_RESPONSES"]
}
const Responses = ({
data: { question, answers, responses, correct, image, media },
}: Props) => {
const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
SFX_ANSWERS_MUSIC,
{
volume: 0.2,
onplay: () => {
setIsMusicPlaying(true)
},
onend: () => {
setIsMusicPlaying(false)
},
},
)
useEffect(() => {
stopMusic()
sfxResults()
setPercentages(calculatePercentages(responses))
}, [responses, playMusic, stopMusic, sfxResults])
useEffect(() => {
if (!isMusicPlaying) {
playMusic()
}
}, [isMusicPlaying, playMusic])
useEffect(() => {
if (!answersMusic) {
return
}
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
}, [answersMusic, isMediaPlaying])
useEffect(() => {
stopMusic()
}, [playMusic, stopMusic])
return (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
onPlayChange={(playing) => setIsMediaPlaying(playing)}
/>
<div
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
>
{answers.map((_, key) => (
<div
key={key}
className={clsx(
"flex flex-col justify-end self-end overflow-hidden rounded-md",
ANSWERS_COLORS[key],
)}
style={{ height: percentages[key] }}
>
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md">
{responses[key] || 0}
</span>
</div>
))}
</div>
</div>
<div>
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
{answers.map((answer, key) => (
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key], {
"opacity-65": responses && correct !== key,
})}
icon={ANSWERS_ICONS[key]}
>
{answer}
</AnswerButton>
))}
</div>
</div>
</div>
)
}
export default Responses

View File

@@ -0,0 +1,52 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import CricleCheck from "@rahoot/web/components/icons/CricleCheck"
import CricleXmark from "@rahoot/web/components/icons/CricleXmark"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { SFX_RESULTS_SOUND } from "@rahoot/web/utils/constants"
import { useEffect } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_RESULT"]
}
const Result = ({
data: { correct, message, points, myPoints, rank, aheadOfMe },
}: Props) => {
const player = usePlayerStore()
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
useEffect(() => {
player.updatePoints(myPoints)
sfxResults()
}, [sfxResults])
return (
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
{correct ? (
<CricleCheck className="aspect-square max-h-60 w-full" />
) : (
<CricleXmark className="aspect-square max-h-60 w-full" />
)}
<h2 className="mt-1 text-4xl font-bold text-white drop-shadow-lg">
{message}
</h2>
<p className="mt-1 text-xl font-bold text-white drop-shadow-lg">
{`You are top ${rank}${aheadOfMe ? `, behind ${aheadOfMe}` : ""}`}
</p>
{correct && (
<span className="mt-2 rounded bg-black/40 px-4 py-2 text-2xl font-bold text-white drop-shadow-lg">
+{points}
</span>
)}
</section>
)
}
export default Result

View File

@@ -0,0 +1,80 @@
"use client"
import { Player } from "@rahoot/common/types/game"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager"
import { useState } from "react"
type Props = {
data: ManagerStatusDataMap["SHOW_ROOM"]
}
const Room = ({ data: { text, inviteCode } }: Props) => {
const { gameId } = useManagerStore()
const { socket } = useSocket()
const { players } = useManagerStore()
const [playerList, setPlayerList] = useState<Player[]>(players)
const [totalPlayers, setTotalPlayers] = useState(0)
useEvent("manager:newPlayer", (player) => {
setPlayerList([...playerList, player])
})
useEvent("manager:removePlayer", (playerId) => {
setPlayerList(playerList.filter((p) => p.id !== playerId))
})
useEvent("manager:playerKicked", (playerId) => {
setPlayerList(playerList.filter((p) => p.id !== playerId))
})
useEvent("game:totalPlayers", (total) => {
setTotalPlayers(total)
})
const handleKick = (playerId: string) => () => {
if (!gameId) {
return
}
socket?.emit("manager:kickPlayer", {
gameId,
playerId,
})
}
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center px-2">
<div className="mb-10 rotate-3 rounded-md bg-white px-6 py-4 text-6xl font-extrabold">
{inviteCode}
</div>
<h2 className="mb-4 text-4xl font-bold text-white drop-shadow-lg">
{text}
</h2>
<div className="mb-6 flex items-center justify-center rounded-full bg-black/40 px-6 py-3">
<span className="text-2xl font-bold text-white drop-shadow-md">
Players Joined: {totalPlayers}
</span>
</div>
<div className="flex flex-wrap gap-3">
{playerList.map((player) => (
<div
key={player.id}
className="shadow-inset bg-primary rounded-md px-4 py-3 font-bold text-white"
onClick={handleKick(player.id)}
>
<span className="cursor-pointer text-xl drop-shadow-md hover:line-through">
{player.username}
</span>
</div>
))}
</div>
</section>
)
}
export default Room

View File

@@ -0,0 +1,57 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import { SFX_BOUMP_SOUND } from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useState } from "react"
import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_START"]
}
const Start = ({ data: { time, subject } }: Props) => {
const [showTitle, setShowTitle] = useState(true)
const [cooldown, setCooldown] = useState(time)
const [sfxBoump] = useSound(SFX_BOUMP_SOUND, {
volume: 0.2,
})
useEvent("game:startCooldown", () => {
sfxBoump()
setShowTitle(false)
})
useEvent("game:cooldown", (sec) => {
sfxBoump()
setCooldown(sec)
})
return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
{showTitle ? (
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{subject}
</h2>
) : (
<>
<div
className={clsx(
`anim-show bg-primary aspect-square h-32 transition-all md:h-60`,
)}
style={{
transform: `rotate(${45 * (time - cooldown)}deg)`,
}}
></div>
<span className="absolute text-6xl font-bold text-white drop-shadow-md md:text-8xl">
{cooldown}
</span>
</>
)}
</section>
)
}
export default Start

View File

@@ -0,0 +1,17 @@
import { PlayerStatusDataMap } from "@rahoot/common/types/game/status"
import Loader from "@rahoot/web/components/Loader"
type Props = {
data: PlayerStatusDataMap["WAIT"]
}
const Wait = ({ data: { text } }: Props) => (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
<Loader />
<h2 className="mt-5 text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{text}
</h2>
</section>
)
export default Wait

View File

@@ -0,0 +1,24 @@
type Props = {
className?: string
fill?: string
}
const Circle = ({ className, fill = "#FFF" }: Props) => (
<svg
className={className}
viewBox="0 0 512 512"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="icon" fill={fill} transform="translate(42.666667, 42.666667)">
<path
d="M213.333333,3.55271368e-14 C331.15408,3.55271368e-14 426.666667,95.5125867 426.666667,213.333333 C426.666667,331.15408 331.15408,426.666667 213.333333,426.666667 C95.5125867,426.666667 3.55271368e-14,331.15408 3.55271368e-14,213.333333 C3.55271368e-14,95.5125867 95.5125867,3.55271368e-14 213.333333,3.55271368e-14 Z"
id="Combined-Shape"
></path>
</g>
</g>
</svg>
)
export default Circle

View File

@@ -0,0 +1,41 @@
type Props = {
className?: string
}
const CricleCheck = ({ className }: Props) => (
<svg
fill="#22c55e"
width="800px"
height="800px"
viewBox="0 0 56.00 56.00"
xmlns="http://www.w3.org/2000/svg"
stroke="#22c55e"
strokeWidth="0.00056"
className={className}
>
<g strokeWidth="0" transform="translate(11.2,11.2), scale(0.6)">
<rect
x="0"
y="0"
width="56.00"
height="56.00"
rx="28"
fill="#ffffff"
strokeWidth="0"
/>
</g>
<g
strokeLinecap="round"
strokeLinejoin="round"
stroke="#CCCCCC"
strokeWidth="0.6719999999999999"
/>
<g>
<path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 24.7655 40.0234 C 23.9687 40.0234 23.3593 39.6719 22.6796 38.8750 L 15.9296 30.5312 C 15.5780 30.0859 15.3671 29.5234 15.3671 29.0078 C 15.3671 27.9063 16.2343 27.0625 17.2655 27.0625 C 17.9452 27.0625 18.5077 27.3203 19.0702 28.0469 L 24.6718 35.2890 L 35.5702 17.8281 C 36.0155 17.1016 36.6249 16.75 37.2343 16.75 C 38.2655 16.75 39.2733 17.4297 39.2733 18.5547 C 39.2733 19.0703 38.9687 19.6328 38.6640 20.1016 L 26.7577 38.8750 C 26.2421 39.6484 25.5858 40.0234 24.7655 40.0234 Z" />
</g>
</svg>
)
export default CricleCheck

View File

@@ -0,0 +1,35 @@
type Props = {
className?: string
}
const CricleXmark = ({ className }: Props) => (
<svg
fill="#ef4444"
width="800px"
height="800px"
viewBox="0 0 56.00 56.00"
xmlns="http://www.w3.org/2000/svg"
stroke="#ef4444"
className={className}
>
<g strokeWidth="0" transform="translate(12.4,12.4), scale(0.6)">
<rect
x="0"
y="0"
width="56.00"
height="56.00"
rx="28"
fill="#ffffff"
strokeWidth="0"
/>
</g>
<g strokeLinecap="round" strokeLinejoin="round" />
<g>
<path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 19.5858 38.4063 C 18.4843 38.4063 17.5936 37.5156 17.5936 36.4141 C 17.5936 35.8750 17.8280 35.4063 18.2030 35.0547 L 25.1874 28.0234 L 18.2030 20.9922 C 17.8280 20.6641 17.5936 20.1719 17.5936 19.6328 C 17.5936 18.5547 18.4843 17.6875 19.5858 17.6875 C 20.1249 17.6875 20.5936 17.8984 20.9452 18.2734 L 27.9765 25.2812 L 35.0546 18.25 C 35.4530 17.8281 35.8749 17.6406 36.3905 17.6406 C 37.4921 17.6406 38.3827 18.5312 38.3827 19.6094 C 38.3827 20.1484 38.1952 20.5937 37.7968 20.9688 L 30.7655 28.0234 L 37.7733 35.0078 C 38.1249 35.3828 38.3593 35.8516 38.3593 36.4141 C 38.3593 37.5156 37.4687 38.4063 36.3671 38.4063 C 35.8046 38.4063 35.3358 38.1719 34.9843 37.8203 L 27.9765 30.7890 L 20.9921 37.8203 C 20.6405 38.1953 20.1249 38.4063 19.5858 38.4063 Z" />
</g>
</svg>
)
export default CricleXmark

View File

@@ -0,0 +1,46 @@
type Props = {
className?: string
fill?: string
stroke?: string
}
const Pentagon = ({ className, fill, stroke }: Props) => (
<svg
className={className}
fill={fill}
height="800px"
width="800px"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
viewBox="-40.96 -40.96 593.93 593.93"
transform="rotate(180)"
stroke={fill}
strokeWidth="0.005120100000000001"
>
<g strokeWidth="0" />
<g
strokeLinecap="round"
strokeLinejoin="round"
stroke={stroke}
strokeWidth="71.6814"
>
<g>
<g>
<path d="M507.804,200.28L262.471,12.866c-3.84-2.923-9.131-2.923-12.949,0L4.188,200.28c-3.605,2.773-5.077,7.531-3.648,11.84 l93.717,281.92c1.451,4.373,5.525,7.296,10.133,7.296h303.253c4.587,0,8.683-2.944,10.133-7.296l93.717-281.92 C512.882,207.789,511.41,203.053,507.804,200.28z" />{" "}
</g>
</g>
</g>
<g>
<g>
<g>
<path d="M507.804,200.28L262.471,12.866c-3.84-2.923-9.131-2.923-12.949,0L4.188,200.28c-3.605,2.773-5.077,7.531-3.648,11.84 l93.717,281.92c1.451,4.373,5.525,7.296,10.133,7.296h303.253c4.587,0,8.683-2.944,10.133-7.296l93.717-281.92 C512.882,207.789,511.41,203.053,507.804,200.28z" />{" "}
</g>
</g>
</g>
</svg>
)
export default Pentagon

View File

@@ -0,0 +1,24 @@
type Props = {
className?: string
fill?: string
}
const Rhombus = ({ className, fill = "#FFF" }: Props) => (
<svg
className={className}
fill={fill}
viewBox="-56.32 -56.32 624.64 624.64"
xmlns="http://www.w3.org/2000/svg"
transform="rotate(45)"
>
<g strokeWidth="0" />
<g strokeLinecap="round" strokeLinejoin="round" />
<g>
<rect x="48" y="48" width="416" height="416" />
</g>
</svg>
)
export default Rhombus

View File

@@ -0,0 +1,17 @@
type Props = {
className?: string
fill?: string
}
const Square = ({ className, fill = "#FFF" }: Props) => (
<svg
className={className}
fill={fill}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="48" y="48" width="416" height="416" />
</svg>
)
export default Square

View File

@@ -0,0 +1,17 @@
type Props = {
className?: string
fill?: string
}
const Triangle = ({ className, fill = "#FFF" }: Props) => (
<svg
className={className}
fill={fill}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<polygon points="256 32 20 464 492 464 256 32" />
</svg>
)
export default Triangle

View File

@@ -0,0 +1,195 @@
/* eslint-disable no-empty-function */
"use client"
import {
ClientToServerEvents,
ServerToClientEvents,
} from "@rahoot/common/types/game/socket"
import ky from "ky"
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react"
import { io, Socket } from "socket.io-client"
import { v7 as uuid } from "uuid"
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>
interface SocketContextValue {
socket: TypedSocket | null
isConnected: boolean
clientId: string
connect: () => void
disconnect: () => void
reconnect: () => void
}
const SocketContext = createContext<SocketContextValue>({
socket: null,
isConnected: false,
clientId: "",
connect: () => {},
disconnect: () => {},
reconnect: () => {},
})
const getSocketServer = async () => {
try {
const res = await ky.get("/socket").json<{ url: string }>()
if (res.url) return res.url
} catch (error) {
console.error("Failed to fetch socket url, using fallback", error)
}
if (typeof window !== "undefined") {
const { protocol, hostname } = window.location
const isHttps = protocol === "https:"
const port =
window.location.port && window.location.port !== "3000"
? window.location.port
: "3001"
const scheme = isHttps ? "https:" : "http:"
return `${scheme}//${hostname}:${port}`
}
return "http://localhost:3001"
}
const getClientId = (): string => {
try {
const stored = localStorage.getItem("client_id")
if (stored) {
return stored
}
const newId = uuid()
localStorage.setItem("client_id", newId)
return newId
} catch {
return uuid()
}
}
export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const [socket, setSocket] = useState<TypedSocket | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [clientId] = useState<string>(() => getClientId())
useEffect(() => {
if (socket) {
return
}
let s: TypedSocket | null = null
const initSocket = async () => {
try {
const socketUrl = await getSocketServer()
const isHttps = socketUrl.startsWith("https")
s = io(socketUrl, {
transports: ["websocket", "polling"],
autoConnect: false,
withCredentials: false,
forceNew: true,
secure: isHttps,
auth: {
clientId,
},
reconnection: true,
reconnectionAttempts: 5,
timeout: 12000,
})
setSocket(s)
s.on("connect", () => {
setIsConnected(true)
})
s.on("disconnect", () => {
setIsConnected(false)
})
s.on("connect_error", (err) => {
console.error("Connection error:", err.message, {
url: socketUrl,
transport: s?.io?.opts?.transports,
})
})
} catch (error) {
console.error("Failed to initialize socket:", error)
}
}
initSocket()
// eslint-disable-next-line consistent-return
return () => {
s?.disconnect()
}
}, [clientId])
const connect = useCallback(() => {
if (socket && !socket.connected) {
socket.connect()
}
}, [socket])
const disconnect = useCallback(() => {
if (socket && socket.connected) {
socket.disconnect()
}
}, [socket])
const reconnect = useCallback(() => {
if (socket) {
socket.disconnect()
socket.connect()
}
}, [socket])
return (
<SocketContext.Provider
value={{
socket,
isConnected,
clientId,
connect,
disconnect,
reconnect,
}}
>
{children}
</SocketContext.Provider>
)
}
export const useSocket = () => useContext(SocketContext)
export const useEvent = <E extends keyof ServerToClientEvents>(
event: E,
callback: ServerToClientEvents[E],
) => {
const { socket } = useSocket()
useEffect(() => {
if (!socket) {
return
}
socket.on(event, callback as any)
// eslint-disable-next-line consistent-return
return () => {
socket.off(event, callback as any)
}
}, [socket, event, callback])
}

14
packages/web/src/env.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
const env = createEnv({
server: {
SOCKET_URL: z.string().default("http://localhost:3001"),
},
runtimeEnv: {
SOCKET_URL: process.env.SOCKET_URL,
},
})
export default env

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from "react"
const useScreenSize = () => {
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
})
useEffect(() => {
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
return screenSize
}
export default useScreenSize

View File

@@ -0,0 +1,257 @@
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"
const toBytes = (valueMb: number) => valueMb * 1024 * 1024
const envMaxMb = Number(process.env.MEDIA_MAX_UPLOAD_MB || process.env.MAX_UPLOAD_MB || 50)
const MAX_UPLOAD_SIZE = Number.isFinite(envMaxMb) && envMaxMb > 0 ? toBytes(envMaxMb) : toBytes(50)
export type StoredMedia = {
fileName: string
url: string
size: number
mime: string
type: QuestionMedia["type"]
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
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()
const map: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".wav": "audio/wav",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".flac": "audio/flac",
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".ogv": "video/ogg",
".mkv": "video/x-matroska",
}
return map[ext] || "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 ${Math.round(MAX_UPLOAD_SIZE / 1024 / 1024)}MB.`,
)
}
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)

View File

@@ -0,0 +1,39 @@
import { Player } from "@rahoot/common/types/game"
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus"
import { create } from "zustand"
type ManagerStore<T> = {
gameId: string | null
status: Status<T> | null
players: Player[]
setGameId: (_gameId: string | null) => void
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
resetStatus: () => void
setPlayers: (_players: Player[] | ((_prev: Player[]) => Player[])) => void
reset: () => void
}
const initialState = {
gameId: null,
status: null,
players: [],
}
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }),
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: null }),
setPlayers: (players) =>
set((state) => ({
players: typeof players === "function" ? players(state.players) : players,
})),
reset: () => set(initialState),
}))

View File

@@ -0,0 +1,82 @@
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus"
import { create } from "zustand"
type PlayerState = {
username?: string
points?: number
}
type PlayerStore<T> = {
gameId: string | null
player: PlayerState | null
status: Status<T> | null
setGameId: (_gameId: string | null) => void
setPlayer: (_state: PlayerState) => void
login: (_gameId: string) => void
join: (_username: string) => void
updatePoints: (_points: number) => void
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
reset: () => void
}
const initialState = {
gameId: null,
player: null,
status: null,
}
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }),
setPlayer: (player: PlayerState) => {
try {
if (player.username) localStorage.setItem("last_username", player.username)
if (typeof player.points === "number") {
localStorage.setItem("last_points", String(player.points))
}
} catch {}
set({ player })
},
login: (username) =>
set((state) => {
try {
localStorage.setItem("last_username", username)
} catch {}
return {
player: { ...state.player, username },
}
}),
join: (gameId) => {
set((state) => ({
gameId,
player: { ...state.player, points: 0 },
}))
},
updatePoints: (points) => {
try {
localStorage.setItem("last_points", String(points))
} catch {}
set((state) => ({
player: { ...state.player, points },
}))
},
setStatus: (name, data) => set({ status: createStatus(name, data) }),
reset: () => {
try {
localStorage.removeItem("last_username")
localStorage.removeItem("last_points")
} catch {}
set(initialState)
},
}))

View File

@@ -0,0 +1,12 @@
import { GameUpdateQuestion } from "@rahoot/common/types/game"
import { create } from "zustand"
type QuestionStore = {
questionStates: GameUpdateQuestion | null
setQuestionStates: (_state: GameUpdateQuestion | null) => void
}
export const useQuestionStore = create<QuestionStore>((set) => ({
questionStates: null,
setQuestionStates: (state) => set({ questionStates: state }),
}))

View File

@@ -0,0 +1,76 @@
import Answers from "@rahoot/web/components/game/states/Answers"
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
import Podium from "@rahoot/web/components/game/states/Podium"
import Prepared from "@rahoot/web/components/game/states/Prepared"
import Question from "@rahoot/web/components/game/states/Question"
import Responses from "@rahoot/web/components/game/states/Responses"
import Result from "@rahoot/web/components/game/states/Result"
import Room from "@rahoot/web/components/game/states/Room"
import Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait"
import { STATUS } from "@rahoot/common/types/game/status"
import Circle from "@rahoot/web/components/icons/Circle"
import Rhombus from "@rahoot/web/components/icons/Rhombus"
import Square from "@rahoot/web/components/icons/Square"
import Triangle from "@rahoot/web/components/icons/Triangle"
export const ANSWERS_COLORS = [
"bg-red-500",
"bg-blue-500",
"bg-yellow-500",
"bg-green-500",
]
export const ANSWERS_ICONS = [Triangle, Rhombus, Circle, Square]
export const GAME_STATES = {
status: {
name: STATUS.WAIT,
data: { text: "Waiting for the players" },
},
question: {
current: 1,
total: null,
},
}
export const GAME_STATE_COMPONENTS = {
[STATUS.SELECT_ANSWER]: Answers,
[STATUS.SHOW_QUESTION]: Question,
[STATUS.WAIT]: Wait,
[STATUS.SHOW_START]: Start,
[STATUS.SHOW_RESULT]: Result,
[STATUS.SHOW_PREPARED]: Prepared,
}
export const GAME_STATE_COMPONENTS_MANAGER = {
...GAME_STATE_COMPONENTS,
[STATUS.SHOW_ROOM]: Room,
[STATUS.SHOW_RESPONSES]: Responses,
[STATUS.SHOW_LEADERBOARD]: Leaderboard,
[STATUS.FINISHED]: Podium,
}
export const SFX_ANSWERS_MUSIC = "/sounds/answersMusic.mp3"
export const SFX_ANSWERS_SOUND = "/sounds/answersSound.mp3"
export const SFX_RESULTS_SOUND = "/sounds/results.mp3"
export const SFX_SHOW_SOUND = "/sounds/show.mp3"
export const SFX_BOUMP_SOUND = "/sounds/boump.mp3"
export const SFX_PODIUM_THREE = "/sounds/three.mp3"
export const SFX_PODIUM_SECOND = "/sounds/second.mp3"
export const SFX_PODIUM_FIRST = "/sounds/first.mp3"
export const SFX_SNEAR_ROOL = "/sounds/snearRoll.mp3"
export const MANAGER_SKIP_BTN = {
[STATUS.SHOW_ROOM]: "Start Game",
[STATUS.SHOW_START]: null,
[STATUS.SHOW_PREPARED]: null,
[STATUS.SHOW_QUESTION]: "Skip",
[STATUS.SELECT_ANSWER]: "Skip",
[STATUS.SHOW_RESULT]: null,
[STATUS.SHOW_RESPONSES]: "Next",
[STATUS.SHOW_LEADERBOARD]: "Next",
[STATUS.FINISHED]: null,
[STATUS.WAIT]: null,
}

View File

@@ -0,0 +1,8 @@
export type Status<T> = {
[K in keyof T]: { name: K; data: T[K] }
}[keyof T]
export const createStatus = <T, K extends keyof T>(
name: K,
data: T[K],
): Status<T> => ({ name, data })

View File

@@ -0,0 +1,23 @@
export const calculatePercentages = (
objectResponses: Record<string, number>,
): Record<string, string> => {
const keys = Object.keys(objectResponses)
const values = Object.values(objectResponses)
if (!values.length) {
return {}
}
const totalSum = values.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0,
)
const result: Record<string, string> = {}
keys.forEach((key) => {
result[key] = `${((objectResponses[key] / totalSum) * 100).toFixed()}%`
})
return result
}