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

42
packages/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -0,0 +1,231 @@
import js from "@eslint/js"
import nextPlugin from "@next/eslint-plugin-next"
import reactPlugin from "eslint-plugin-react"
import reactHooksPlugin from "eslint-plugin-react-hooks"
import { defineConfig } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
{
ignores: ["**/node_modules/**", "**/.next/**"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
...js.configs.recommended.languageOptions,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
"@next/next": nextPlugin,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].rules,
...reactPlugin.configs.recommended.rules,
"array-callback-return": [
"error",
{ allowImplicit: false, checkForEach: true, allowVoid: true },
],
"no-await-in-loop": "error",
"no-constant-binary-expression": "error",
"no-constructor-return": "error",
"no-duplicate-imports": ["error", { includeExports: true }],
"no-new-native-nonconstructor": "error",
"no-promise-executor-return": ["error", { allowVoid: true }],
"no-self-compare": "error",
"no-template-curly-in-string": "error",
"no-unmodified-loop-condition": "error",
"no-unreachable-loop": "error",
"no-unused-private-class-members": "error",
"arrow-body-style": ["error", "as-needed"],
camelcase: [
"error",
{
properties: "always",
ignoreDestructuring: true,
ignoreImports: true,
ignoreGlobals: true,
},
],
"capitalized-comments": [
"error",
"always",
{ ignoreConsecutiveComments: true },
],
"class-methods-use-this": ["error", { enforceForClassFields: true }],
complexity: ["warn", 40],
"consistent-return": "error",
curly: ["error", "all"],
"default-param-last": "error",
"dot-notation": "error",
eqeqeq: ["error", "always"],
"func-name-matching": "error",
"func-names": "error",
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"grouped-accessor-pairs": ["error", "getBeforeSet"],
"guard-for-in": "error",
"init-declarations": ["error", "always"],
"logical-assignment-operators": [
"error",
"always",
{ enforceForIfStatements: true },
],
"max-classes-per-file": ["error", { ignoreExpressions: true }],
"max-depth": ["error", 3],
"max-lines": [
"error",
{ max: 500, skipBlankLines: true, skipComments: true },
],
"max-nested-callbacks": ["error", 3],
"max-params": ["error", 4],
"multiline-comment-style": ["error", "separate-lines"],
"no-alert": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-else-return": "error",
"no-empty-function": "error",
"no-empty-static-block": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-label": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "error",
"no-invalid-this": "error",
"no-iterator": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-multi-assign": "error",
"no-multi-str": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-object-constructor": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-plusplus": "error",
"no-proto": "error",
"no-return-assign": ["error", "always"],
"no-script-url": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
"no-unused-expressions": ["error", { enforceForJSX: true }],
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-useless-call": "error",
"no-useless-computed-key": ["error", { enforceForClassMembers: true }],
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-warning-comments": ["error", { terms: ["todo"] }],
"object-shorthand": ["error", "always"],
"one-var": ["error", "never"],
"operator-assignment": ["error", "always"],
"prefer-arrow-callback": "error",
"prefer-const": [
"error",
{ destructuring: "any", ignoreReadBeforeAssign: false },
],
"prefer-destructuring": "error",
"prefer-exponentiation-operator": "error",
"prefer-numeric-literals": "error",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
radix: "error",
"require-await": "error",
"require-unicode-regexp": "error",
"symbol-description": "error",
yoda: "error",
"line-comment-position": ["error", { position: "above" }],
indent: "off",
"newline-before-return": "error",
"no-undef": "error",
"padded-blocks": ["error", "never"],
"padding-line-between-statements": [
"error",
{
blankLine: "always",
prev: "*",
next: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
},
{
blankLine: "always",
prev: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
next: "*",
},
],
quotes: [
"error",
"double",
{ avoidEscape: true, allowTemplateLiterals: true },
],
"space-before-blocks": "error",
semi: ["error", "never"],
// React + Hooks + Next.js
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": ["error", { forbid: [">", "}"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "off",
},
},
])

View File

@@ -0,0 +1,10 @@
const nextConfig = {
output: "standalone",
productionBrowserSourceMaps: false,
transpilePackages: ["packages/*", "@t3-oss/env-nextjs"],
eslint: {
ignoreDuringBuilds: true,
},
}
export default nextConfig

46
packages/web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@rahoot/web",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@rahoot/common": "workspace:*",
"@rahoot/socket": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"clsx": "^2.1.1",
"ky": "^1.13.0",
"motion": "^12.23.24",
"next": "15.5.4",
"react": "19.1.0",
"react-confetti": "^6.4.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"socket.io-client": "^4.8.1",
"use-sound": "^5.0.0",
"uuid": "^13.0.0",
"yup": "^1.7.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@types/node": "^20.19.23",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"eslint": "^9.38.0",
"eslint-config-next": "15.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="100%" height="100%" viewBox="5.989999771118164 -63.970001220703125 88.26000213623047 65.80000305175781"><g fill="#ff9900"><path d="M65.97 -41.84C65.97 -45.17 65.2 -48.32 63.68 -51.28C62.15 -54.25 59.98 -56.62 57.15 -58.4C55.98 -59.12 54.67 -59.7 53.2 -60.14C51.73 -60.59 50.2 -60.93 48.62 -61.18C47.04 -61.43 45.48 -61.59 43.92 -61.64C42.37 -61.7 40.93 -61.72 39.6 -61.72C34.38 -61.72 29.18 -61.56 24 -61.23C18.81 -60.89 13.61 -60.59 8.4 -60.31C8.07 -51.33 7.82 -42.34 7.65 -33.36C7.6 -28.31 7.4 -23.25 7.07 -18.18C6.74 -13.1 6.38 -8.01 5.99 -2.91C7.54 -2.75 9.08 -2.63 10.61 -2.58C12.13 -2.52 13.7 -2.5 15.31 -2.5C18.47 -2.5 21.63 -2.58 24.79 -2.75L24.37 -21.63C26.15 -21.74 27.94 -21.88 29.74 -22.04C31.54 -22.21 33.36 -22.35 35.19 -22.46C36.8 -18.3 38.04 -14.34 38.93 -10.56C39.82 -6.79 40.48 -2.66 40.93 1.83L65.22 1.25C61.11 -8.35 56.32 -17.72 50.83 -26.87C55.37 -27.09 59.04 -28.51 61.81 -31.11C64.58 -33.72 65.97 -37.3 65.97 -41.84ZM46.67 -43.17C46.67 -42.9 46.65 -42.43 46.63 -41.76C46.6 -41.09 46.57 -40.42 46.54 -39.72C46.52 -39.03 46.45 -38.36 46.34 -37.73C46.22 -37.09 46.09 -36.66 45.92 -36.44C45.53 -35.94 44.85 -35.53 43.88 -35.23C42.91 -34.92 41.86 -34.69 40.72 -34.52C39.58 -34.36 38.47 -34.25 37.39 -34.19C36.31 -34.13 35.49 -34.11 34.94 -34.11C33.66 -34.11 32.36 -34.16 31.03 -34.27C29.7 -34.38 28.42 -34.58 27.2 -34.86L24.96 -50.99C27.06 -51.33 29.18 -51.6 31.32 -51.83C33.46 -52.05 35.63 -52.16 37.85 -52.16C38.13 -52.16 38.61 -52.14 39.31 -52.12C40 -52.09 40.72 -52.03 41.47 -51.95C42.22 -51.87 42.92 -51.77 43.59 -51.66C44.26 -51.55 44.73 -51.38 45 -51.16C45.39 -50.83 45.7 -50.31 45.92 -49.62C46.14 -48.93 46.31 -48.18 46.42 -47.38C46.53 -46.57 46.6 -45.78 46.63 -45C46.65 -44.23 46.67 -43.62 46.67 -43.17Z M94.25 -63.97L74.87 -62.39C75.09 -58.56 75.28 -54.75 75.45 -50.95C75.62 -47.15 75.89 -43.37 76.28 -39.6L78.36 -18.63L87.51 -19.47C88.34 -23.63 89.19 -27.73 90.05 -31.78C90.91 -35.83 91.76 -39.93 92.59 -44.09C92.86 -45.53 93.1 -47.1 93.29 -48.79C93.49 -50.48 93.64 -52.21 93.75 -53.99C93.86 -55.76 93.96 -57.51 94.04 -59.23C94.13 -60.95 94.2 -62.53 94.25 -63.97ZM92.75 -6.65C92.75 -6.88 92.74 -7.08 92.71 -7.28C92.69 -7.47 92.62 -7.68 92.5 -7.9C91.89 -9.34 90.97 -10.43 89.72 -11.15C88.47 -11.87 87.07 -12.23 85.52 -12.23C84.57 -12.23 83.56 -12.08 82.48 -11.77C81.4 -11.47 80.4 -11.02 79.49 -10.44C78.57 -9.86 77.79 -9.14 77.16 -8.28C76.52 -7.42 76.2 -6.46 76.2 -5.41C76.2 -4.41 76.44 -3.52 76.91 -2.75C77.38 -1.97 77.99 -1.32 78.74 -0.79C79.49 -0.26 80.32 0.15 81.23 0.46C82.15 0.76 83.02 0.92 83.85 0.92C84.74 0.92 85.71 0.71 86.76 0.29C87.82 -0.12 88.79 -0.69 89.68 -1.41C90.56 -2.14 91.3 -2.95 91.88 -3.87C92.46 -4.78 92.75 -5.71 92.75 -6.65Z"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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
}

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
],
"exclude": ["node_modules"]
}