refactor: add typescript & pnpm workspace & docker file

This commit is contained in:
Ralex committed 2025-10-16 23:12:40 +02:00
1 parent 8f73241f34
commit edb7146d6d
122 files changed
+7568 -8502

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
.pnpm-store
.git
+2
View File
@@ -0,0 +1,2 @@
SOCKET_URL=http://localhost:3001 # Default: http://localhost:3001
SOCKER_PORT=3001 # Default: 3001
+1 -36
View File
@@ -1,37 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules /node_modules
/socket/node_modules .env
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+2
View File
@@ -2,6 +2,8 @@
"recommendations": [ "recommendations": [
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"usernamehw.errorlens",
"YoavBls.pretty-ts-errors",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"meganrogge.template-string-converter", "meganrogge.template-string-converter",
"vincaslt.highlight-matching-tag", "vincaslt.highlight-matching-tag",
+66
View File
@@ -0,0 +1,66 @@
FROM node:24-alpine AS base
# Enable and prepare pnpm via Corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
# ----- DEPENDENCIES -----
FROM base AS deps
WORKDIR /app
# Copy pnpm configuration files
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/web/package.json ./packages/web/
COPY packages/socket/package.json ./packages/socket/
# Install only production dependencies
RUN pnpm install --frozen-lockfile --prod
# ----- BUILDER -----
FROM base AS builder
WORKDIR /app
# Copy all monorepo files
COPY . .
# Install all dependencies (including dev) for build
RUN pnpm install --frozen-lockfile
# Build Next.js app with standalone output for smaller runtime image
WORKDIR /app/packages/web
RUN pnpm build
# Build socket server if needed (TypeScript or similar)
WORKDIR /app/packages/socket
RUN if [ -f "tsconfig.json" ]; then pnpm build; fi
# ----- RUNNER -----
FROM node:24-alpine AS runner
WORKDIR /app
# Create a non-root user for better security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
# Enable pnpm in the runtime image
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy configuration files
COPY pnpm-workspace.yaml package.json ./
# Copy the Next.js standalone build
COPY --from=builder /app/packages/web/.next/standalone ./
COPY --from=builder /app/packages/web/.next/static ./packages/web/.next/static
COPY --from=builder /app/packages/web/public ./packages/web/public
# Copy the socket server build
COPY --from=builder /app/packages/socket/dist ./packages/socket/dist
# Expose the web and socket ports
EXPOSE 3000 5505
# Environment variables
ENV NODE_ENV=production
# Start both services (Next.js web app + Socket server)
CMD ["sh", "-c", "node packages/web/server.js & node packages/socket/dist/index.cjs"]
-103
View File
@@ -1,103 +0,0 @@
export const WEBSOCKET_PUBLIC_URL = "http://localhost:5505/"
export const WEBSOCKET_SERVER_PORT = 5505
const QUIZZ_CONFIG = {
password: "PASSWORD",
subject: "Adobe",
questions: [
{
question: "Who are the founders of Adobe?",
answers: [
"Steve Jobs and Charles Geschke",
"Jhon Warnock and Charles Geschke",
"Jhon Jonse and Charles Geskie",
"Bill Gate",
],
solution: 1,
cooldown: 5,
time: 15,
},
{
question: "What is Adobe's most famous software?",
answers: ["Encore", "AfterEffect", "Creative Cloud", "Photoshop"],
image:
"https://images.unsplash.com/photo-1626785774573-4b799315345d?q=80&w=500&auto=webp",
solution: 3,
cooldown: 5,
time: 15,
},
{
question: "When was Adobe created?",
answers: ["2000", "1982", "2003", "1987"],
solution: 1,
cooldown: 5,
time: 15,
},
{
question: "Where is headquertes located?",
answers: [
"San Jose, California",
"Bookworm, Cascui",
"DowTown, Texas",
"Tokyo, Japan",
],
solution: 0,
cooldown: 5,
time: 15,
},
{
question: "How many employees at Adobe?",
answers: [
"15,423 employees",
"30,803 employees",
"25,988 employees",
"5,073 employees",
],
image:
"https://images.unsplash.com/photo-1504384308090-c894fdcc538d?q=80&w=500&auto=webp",
solution: 2,
cooldown: 5,
time: 15,
},
{
question: "Who is the Current CEO?",
answers: [
"Jhon Warnock",
"Victor Newway",
"Mark Java",
"Shantanu Narayen",
],
image:
"https://images.unsplash.com/photo-1435348773030-a1d74f568bc2?q=80&w=500&auto=webp",
solution: 3,
cooldown: 5,
time: 15,
},
{
question: "Adobe's core business is focused on?",
answers: [
"Creative Software",
"Video Game",
"Logistics software",
"Other",
],
image:
"https://images.unsplash.com/photo-1582736317407-371893d9e146?q=80&w=500&auto=webp",
solution: 0,
cooldown: 5,
time: 15,
},
],
}
// DONT CHANGE
export const GAME_STATE_INIT = {
started: false,
players: [],
playersAnswer: [],
manager: null,
room: null,
currentQuestion: 0,
roundStartTime: 0,
...QUIZZ_CONFIG,
}
+4
View File
@@ -0,0 +1,4 @@
{
"managerPassword": "PASSWORD",
"music": true
}
+28
View File
@@ -0,0 +1,28 @@
{
"subject": "Example Quizz",
"questions": [
{
"question": "What is good answer ?",
"answers": ["No", "Good answer", "No", "No"],
"solution": 1,
"cooldown": 5,
"time": 15
},
{
"question": "What is good answer with image ?",
"answers": ["No", "No", "No", "Good answer"],
"image": "https://placehold.co/600x400.png",
"solution": 3,
"cooldown": 5,
"time": 20
},
{
"question": "What is good answer with two answers ?",
"answers": ["Good answer", "No"],
"image": "https://placehold.co/600x400.png",
"solution": 0,
"cooldown": 5,
"time": 20
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
},
},
}
-16
View File
@@ -1,16 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
port: "",
pathname: "/**",
},
],
},
}
export default nextConfig
-6851
View File
File diff suppressed because it is too large. Load diff
+8 -29
View File
@@ -1,36 +1,15 @@
{ {
"name": "rahoot", "name": "rahoot",
"version": "0.1.0",
"private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "dotenv -e .env -- pnpm -r --parallel dev",
"build": "next build", "dev:web": "dotenv -e .env -- pnpm --filter web dev",
"start": "next start", "dev:socket": "dotenv -e .env -- pnpm --filter socket dev",
"socket": "node ./socket/index.js", "build": "pnpm -r run build",
"all": "npm run build && concurrently --kill-others \"npm run start\" \"npm run socket\"", "start": "pnpm -r --parallel run start",
"all-dev": "concurrently --kill-others \"npm run dev\" \"npm run socket\"", "clean": "pnpm -r exec rm -rf dist node_modules"
"lint": "eslint .",
"next:lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.0",
"concurrently": "^8.2.2",
"next": "^14.2.32",
"react": "^18",
"react-confetti": "^6.1.0",
"react-dom": "^18",
"react-hot-toast": "^2.4.1",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4",
"use-sound": "^4.0.1",
"yup": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.0.1", "dotenv-cli": "^10.0.0",
"eslint": "^9.36.0", "typescript": "^5.0.0"
"eslint-config-next": "^15.5.4",
"postcss": "^8",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.3.0"
} }
} }
+1
View File
@@ -0,0 +1 @@
node_modules
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@rahoot/common",
"version": "1.0.0",
"type": "module",
"dependencies": {
"socket.io": "^4.8.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
}
+30
View File
@@ -0,0 +1,30 @@
export type Player = {
id: string
username: string
points: number
}
export type Answer = {
playerId: string
answerId: number
points: number
}
export type Quizz = {
subject: string
questions: {
question: string
image?: string
answers: string[]
solution: number
cooldown: number
time: number
}[]
}
export type QuizzWithId = Quizz & { id: string }
export type GameUpdateQuestion = {
current: number
total: number
}
+72
View File
@@ -0,0 +1,72 @@
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
import { Player, QuizzWithId } from "."
import { Status, StatusDataMap } from "./status"
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
export type Socket = SocketIO<ClientToServerEvents, ServerToClientEvents>
export type Message<K extends keyof StatusDataMap = keyof StatusDataMap> = {
gameId?: string
status: K
data: StatusDataMap[K]
}
export type MessageWithoutStatus<T = any> = {
gameId?: string
data: T
}
export type MessageGameId = {
gameId?: string
}
export interface ServerToClientEvents {
// Game events
"game:status": (data: { name: Status; data: StatusDataMap[Status] }) => void
"game:successRoom": (data: string) => void
"game:successJoin": (gameId: string) => void
"game:totalPlayers": (count: number) => void
"game:errorMessage": (message: string) => void
"game:startCooldown": () => void
"game:cooldown": (count: number) => void
"game:kick": () => void
"game:reset": () => void
"game:updateQuestion": (data: { current: number; total: number }) => void
"game:playerAnswer": (count: number) => void
"player:updateLeaderboard": (data: { leaderboard: Player[] }) => void
// Manager events
"manager:quizzList": (quizzList: QuizzWithId[]) => void
"manager:gameCreated": (data: { gameId: string; inviteCode: string }) => void
"manager:statusUpdate": (data: {
status: Status
data: StatusDataMap[Status]
}) => void
"manager:newPlayer": (player: Player) => void
"manager:removePlayer": (playerId: string) => void
"manager:errorMessage": (message: string) => void
"manager:playerKicked": (playerId: string) => void
}
export interface ClientToServerEvents {
// Manager actions
"game:create": (quizzId: string) => void
"manager:auth": (password: string) => void
"manager:kickPlayer": (
message: MessageWithoutStatus<{ playerId: string }>
) => void
"manager:startGame": (message: MessageGameId) => void
"manager:abortQuiz": (message: MessageGameId) => void
"manager:nextQuestion": (message: MessageGameId) => void
"manager:showLeaderboard": (message: MessageGameId) => void
// Player actions
"player:join": (inviteCode: string) => void
"player:login": (message: MessageWithoutStatus<{ username: string }>) => void
"player:selectedAnswer": (
message: MessageWithoutStatus<{ answerKey: number }>
) => void
// Common
disconnect: () => void
}
+58
View File
@@ -0,0 +1,58 @@
import { Player } from ".";
export enum Status {
SHOW_ROOM = "SHOW_ROOM",
SHOW_START = "SHOW_START",
SHOW_PREPARED = "SHOW_PREPARED",
SHOW_QUESTION = "SHOW_QUESTION",
SELECT_ANSWER = "SELECT_ANSWER",
SHOW_RESULT = "SHOW_RESULT",
SHOW_RESPONSES = "SHOW_RESPONSES",
SHOW_LEADERBOARD = "SHOW_LEADERBOARD",
FINISHED = "FINISHED",
WAIT = "WAIT",
}
export type CommonStatusDataMap = {
SHOW_START: { time: number; subject: string }
SHOW_PREPARED: { totalAnswers: number; questionNumber: number }
SHOW_QUESTION: { question: string; image?: string; cooldown: number }
SELECT_ANSWER: {
question: string
answers: string[]
image?: string
time: number
totalPlayer: number
}
SHOW_RESULT: {
correct: boolean
message: string
points: number
myPoints: number
rank: number
aheadOfMe: string | null
}
WAIT: { text: string }
FINISHED: { subject: string; top: Player[] }
}
type ManagerExtraStatus = {
SHOW_ROOM: { text: string; inviteCode?: string }
SHOW_RESPONSES: {
question: string
responses: Record<number, number>
correct: number
answers: string[]
image?: string
}
SHOW_LEADERBOARD: { leaderboard: Player[] }
}
type PlayerExtraStatus = {
WAIT: { text: string }
}
export type PlayerStatusDataMap = CommonStatusDataMap & PlayerExtraStatus
export type ManagerStatusDataMap = CommonStatusDataMap & ManagerExtraStatus
export type StatusDataMap = PlayerStatusDataMap & ManagerStatusDataMap
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+19
View File
@@ -0,0 +1,19 @@
import esbuild from "esbuild"
import path from "path"
export const config = {
entryPoints: ["src/index.ts"],
bundle: true,
minify: true,
platform: "node",
outfile: "dist/index.cjs",
sourcemap: true,
define: {
"process.env.NODE_ENV": '"production"',
},
alias: {
"@": path.resolve("./src"),
},
}
esbuild.build(config)
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@rahoot/socket",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "node esbuild.config.js",
"start": "node dist/index.cjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.11"
},
"devDependencies": {
"esbuild": "^0.25.10",
"tsx": "^4.20.6"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod/v4"
const env = createEnv({
server: {
SOCKER_PORT: z.string().optional().default("3001"),
},
runtimeEnv: {
SOCKER_PORT: process.env.SOCKER_PORT,
},
})
export default env
+159
View File
@@ -0,0 +1,159 @@
import { Server, Socket } from "@rahoot/common/types/game/socket"
import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game"
import { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO()
Config.init()
let games: Game[] = []
const port = env.SOCKER_PORT || 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
function withGame<T>(
gameId: string | undefined,
socket: Socket,
games: Game[],
handler: (game: Game) => T
): T | void {
let game = null
if (gameId) {
game = games.find((g) => g.gameId === gameId)
} else {
game = games.find(
(g) =>
g.players.find((p) => p.id === socket.id) || g.managerId === socket.id
)
}
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
return handler(game)
}
io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`)
console.log(socket.handshake.auth)
socket.on("manager:auth", (password) => {
try {
const config = Config.game()
if (password !== config.managerPassword) {
socket.emit("manager:errorMessage", "Invalid password")
return
}
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to read game config:", error)
socket.emit("manager:errorMessage", "Failed to read game config")
}
})
socket.on("game:create", (quizzId) => {
const quizzList = Config.quizz()
const quizz = quizzList.find((q) => q.id === quizzId)
if (!quizz) {
socket.emit("game:errorMessage", "Quizz not found")
return
}
const game = new Game(io, socket, quizz)
games.push(game)
})
socket.on("player:join", async (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message)
return
}
const game = games.find((g) => g.inviteCode === inviteCode)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
socket.emit("game:successRoom", game.gameId)
})
socket.on("player:login", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.kickPlayer(socket, data.playerId)
)
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.abortRound(socket))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.nextRound(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard(socket))
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.managerId === socket.id)
if (managerGame) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset")
games = games.filter((g) => g.gameId !== managerGame.gameId)
return
}
const game = games.find((g) => g.players.some((p) => p.id === socket.id))
if (game) {
const player = game.players.find((p) => p.id === socket.id)
if (player) {
game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.managerId).emit("manager:removePlayer", player.id)
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
console.log(
`Removed player ${player.username} from game ${game.gameId}`
)
}
}
})
})
+110
View File
@@ -0,0 +1,110 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const getPath = (path: string) => resolve(process.cwd(), "../../config", path)
class Config {
static init() {
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
fs.writeFileSync(
getPath("game.json"),
JSON.stringify(
{
managerPassword: "PASSWORD",
music: true,
},
null,
2
)
)
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
fs.writeFileSync(
getPath("quizz/example.json"),
JSON.stringify(
{
subject: "Example Quizz",
questions: [
{
question: "What is good answer ?",
answers: ["No", "Good answer", "No", "No"],
solution: 1,
cooldown: 5,
time: 15,
},
{
question: "What is good answer with image ?",
answers: ["No", "No", "No", "Good answer"],
image: "https://placehold.co/600x400.png",
solution: 3,
cooldown: 5,
time: 20,
},
{
question: "What is good answer with two answers ?",
answers: ["Good answer", "No"],
image: "https://placehold.co/600x400.png",
solution: 0,
cooldown: 5,
time: 20,
},
],
},
null,
2
)
)
}
}
static game() {
const isExists = fs.existsSync(getPath("game.json"))
if (!isExists) {
throw new Error("Game config not found")
}
try {
const config = fs.readFileSync(getPath("game.json"), "utf-8")
return JSON.parse(config)
} catch (error) {
console.error("Failed to read game config:", error)
}
}
static quizz() {
const isExists = fs.existsSync(getPath("quizz"))
if (!isExists) {
return []
}
try {
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
const quizz: QuizzWithId[] = files.map((file) => {
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
const config = JSON.parse(data)
const id = file.replace(".json", "")
return {
id,
...config,
}
})
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
return []
}
}
}
export default Config
+384
View File
@@ -0,0 +1,384 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game"
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Status } from "@rahoot/common/types/game/status"
import createInviteCode from "@rahoot/socket/utils/inviteCode"
import { v4 as uuid } from "uuid"
import sleep from "../utils/sleep"
class Game {
io: Server
gameId: string
managerId: string
inviteCode: string
started: boolean
status: Status
quizz: Quizz
players: Player[]
round: {
currentQuestion: number
playersAnswers: Answer[]
startTime: number
}
cooldown: {
active: boolean
ms: number
}
constructor(io: Server, socket: Socket, quizz: Quizz) {
if (!io) {
throw new Error("Socket server not initialized")
}
this.io = io
this.gameId = uuid()
this.managerId = ""
this.inviteCode = ""
this.started = false
this.status = Status.SHOW_START
this.players = []
this.round = {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
this.cooldown = {
active: false,
ms: 0,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.managerId = socket.id
this.quizz = quizz
socket.join(this.gameId)
socket.emit("manager:gameCreated", {
gameId: this.gameId,
inviteCode: roomInvite,
})
console.log(
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
)
}
join(socket: Socket, username: string) {
socket.join(this.gameId)
const playerData = {
id: socket.id,
username: username,
points: 0,
}
this.players.push(playerData)
this.io.to(this.managerId).emit("manager:newPlayer", playerData)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
socket.emit("game:successJoin", this.gameId)
}
kickPlayer(socket: Socket, playerId: string) {
if (this.managerId !== socket.id) {
return
}
const player = this.players.find((p) => p.id === playerId)
if (!player) {
return
}
this.players = this.players.filter((p) => p.id !== playerId)
this.io.in(playerId).socketsLeave(this.gameId)
this.io.to(player.id).emit("game:kick")
this.io.to(this.managerId).emit("manager:playerKicked", player.id)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
}
async startCooldown(seconds: number) {
if (this.cooldown.active) {
return
}
this.cooldown.active = true
let count = seconds - 1
return new Promise<void>((resolve) => {
const cooldownTimeout = setInterval(() => {
if (!this.cooldown.active || count <= 0) {
this.cooldown.active = false
clearInterval(cooldownTimeout)
resolve()
} else {
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}
}, 1000)
})
}
async abortCooldown() {
if (this.cooldown.active) {
this.cooldown.active = false
}
}
async start(socket: Socket) {
if (this.managerId !== socket.id) {
return
}
if (this.started) {
return
}
this.started = true
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_START,
data: {
time: 3,
subject: this.quizz.subject,
},
})
await sleep(3)
this.io.to(this.gameId).emit("game:startCooldown")
await this.startCooldown(3)
this.newRound()
}
async newRound() {
const question = this.quizz.questions[this.round.currentQuestion]
if (!this.started) {
return
}
this.io.to(this.gameId).emit("game:updateQuestion", {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
})
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_PREPARED,
data: {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
},
})
await sleep(2)
if (!this.started) {
return
}
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_QUESTION,
data: {
question: question.question,
image: question.image,
cooldown: question.cooldown,
},
})
await sleep(question.cooldown)
if (!this.started) {
return
}
this.round.startTime = Date.now()
this.io.to(this.gameId).emit("game:status", {
name: Status.SELECT_ANSWER,
data: {
question: question.question,
answers: question.answers,
image: question.image,
time: question.time,
totalPlayer: this.players.length,
},
})
await this.startCooldown(question.time)
if (!this.started) {
return
}
this.players = this.players.map((player) => {
const playerAnswer = this.round.playersAnswers.find(
(a) => a.playerId === player.id
)
const isCorrect = playerAnswer
? playerAnswer.answerId === question.solution
: false
const points =
playerAnswer && isCorrect
? Math.round(playerAnswer && playerAnswer.points)
: 0
player.points += points
const sortPlayers = this.players.sort((a, b) => b.points - a.points)
const rank = sortPlayers.findIndex((p) => p.id === player.id) + 1
const aheadPlayer = sortPlayers[rank - 2]
this.io.to(player.id).emit("game:status", {
name: Status.SHOW_RESULT,
data: {
correct: isCorrect,
message: isCorrect ? "Nice !" : "Too bad",
points,
myPoints: player.points,
rank,
aheadOfMe: aheadPlayer ? aheadPlayer.username : null,
},
})
return player
})
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
)
// Manager
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_RESPONSES,
data: {
question: question.question,
responses: totalType,
correct: question.solution,
answers: question.answers,
image: question.image,
},
})
this.round.playersAnswers = []
}
timeToPoint(startTime: number, secondes: number) {
let points = 1000
const actualTime = Date.now()
const tempsPasseEnSecondes = (actualTime - startTime) / 1000
points -= (1000 / secondes) * tempsPasseEnSecondes
points = Math.max(0, points)
return points
}
async selectAnswer(socket: Socket, answerId: number) {
const player = this.players.find((player) => player.id === socket.id)
const question = this.quizz.questions[this.round.currentQuestion]
if (!player) {
return
}
if (this.round.playersAnswers.find((p) => p.playerId === socket.id)) {
return
}
this.round.playersAnswers.push({
playerId: player.id,
answerId,
points: this.timeToPoint(this.round.startTime, question.time),
})
socket.emit("game:status", {
name: Status.WAIT,
data: { text: "Waiting for the players to answer" },
})
socket
.to(this.gameId)
.emit("game:playerAnswer", this.round.playersAnswers.length)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
if (this.round.playersAnswers.length === this.players.length) {
this.abortCooldown()
}
}
nextRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.managerId) {
return
}
if (!this.quizz.questions[this.round.currentQuestion + 1]) {
return
}
this.round.currentQuestion += 1
this.newRound()
}
abortRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.managerId) {
return
}
this.abortCooldown()
}
showLeaderboard(socket: Socket) {
const isLastRound =
this.round.currentQuestion + 1 === this.quizz.questions.length
const sortedPlayers = this.players.sort((a, b) => b.points - a.points)
if (isLastRound) {
socket.emit("game:status", {
name: Status.FINISHED,
data: {
subject: this.quizz.subject,
top: sortedPlayers.slice(0, 3),
},
})
return
}
socket.emit("game:status", {
name: Status.SHOW_LEADERBOARD,
data: {
leaderboard: sortedPlayers.slice(0, 5),
},
})
}
}
export default Game
@@ -1,4 +1,4 @@
const generateRoomId = (length = 6) => { const createInviteCode = (length = 6) => {
let result = "" let result = ""
const characters = "0123456789" const characters = "0123456789"
const charactersLength = characters.length const charactersLength = characters.length
@@ -11,4 +11,4 @@ const generateRoomId = (length = 6) => {
return result return result
} }
export default generateRoomId export default createInviteCode
+4
View File
@@ -0,0 +1,4 @@
export const sleep = (sec: number) =>
new Promise((r) => void setTimeout(r, sec * 1000))
export default sleep
+8
View File
@@ -0,0 +1,8 @@
import z from "zod"
export const usernameValidator = z
.string()
.min(4, "Username cannot be less than 4 characters")
.max(20, "Username cannot exceed 20 characters")
export const inviteCodeValidator = z.string().length(6, "Invalid invite code")
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}
+42
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
File renamed without changes.
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -1,5 +1,6 @@
// eslint.config.js
import nextPlugin from "@next/eslint-plugin-next" import nextPlugin from "@next/eslint-plugin-next"
import tsEslintPlugin from "@typescript-eslint/eslint-plugin"
import tsEslintParser from "@typescript-eslint/parser"
import reactPlugin from "eslint-plugin-react" import reactPlugin from "eslint-plugin-react"
import reactHooksPlugin from "eslint-plugin-react-hooks" import reactHooksPlugin from "eslint-plugin-react-hooks"
import { defineConfig } from "eslint/config" import { defineConfig } from "eslint/config"
@@ -10,10 +11,11 @@ export default defineConfig([
ignores: ["**/node_modules/**", "**/.next/**"], ignores: ["**/node_modules/**", "**/.next/**"],
}, },
{ {
files: ["**/*.js", "**/*.jsx"], files: ["**/*.ts", "**/*.tsx"],
languageOptions: { languageOptions: {
ecmaVersion: "latest", ecmaVersion: "latest",
sourceType: "module", sourceType: "module",
parser: tsEslintParser,
parserOptions: { parserOptions: {
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
@@ -23,6 +25,7 @@ export default defineConfig([
}, },
}, },
plugins: { plugins: {
"@typescript-eslint": tsEslintPlugin,
react: reactPlugin, react: reactPlugin,
"react-hooks": reactHooksPlugin, "react-hooks": reactHooksPlugin,
"@next/next": nextPlugin, "@next/next": nextPlugin,
+10
View File
@@ -0,0 +1,10 @@
const nextConfig = {
output: "standalone",
productionBrowserSourceMaps: false,
transpilePackages: ["packages/*", "@t3-oss/env-nextjs"],
eslint: {
ignoreDuringBuilds: true,
},
}
export default nextConfig
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@rahoot/web",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/packages/web/server.js",
"lint": "eslint"
},
"dependencies": {
"@rahoot/common": "workspace:*",
"@rahoot/socket": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"clsx": "^2.1.1",
"ky": "^1.11.0",
"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",
"yup": "^1.7.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9",
"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",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+17
View File
@@ -0,0 +1,17 @@
"use client"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => {
const { isConnected, connect } = useSocket()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
return children
}
export default AuthLayout
@@ -0,0 +1,43 @@
"use client"
import { QuizzWithId } from "@rahoot/common/types/game"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerGameStore } from "@rahoot/web/stores/game"
import { useRouter } from "next/navigation"
import { useState } from "react"
export default function Manager() {
const { setStatus } = useManagerGameStore()
const router = useRouter()
const { socket } = useSocket()
const [isAuth, setIsAuth] = useState(false)
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true)
setQuizzList(quizzList)
})
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
setStatus("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) => {
console.log(quizzId)
socket?.emit("game:create", quizzId)
console.log("create room")
}
if (!isAuth) {
return <ManagerPassword onSubmit={handleAuth} />
}
return <SelectQuizz quizzList={quizzList} onSelect={handleCreate} />
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import logo from "@rahoot/web/assets/logo.svg"
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 Image from "next/image"
import { useEffect } from "react"
import toast from "react-hot-toast"
export default function Home() {
const { isConnected, connect } = useSocket()
const { player } = usePlayerStore()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:errorMessage", (message) => {
toast.error(message)
})
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="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" />
{!player ? <Room /> : <Username />}
</section>
)
}
@@ -0,0 +1,78 @@
"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 } from "@rahoot/web/contexts/socketProvider"
import { usePlayerGameStore } from "@rahoot/web/stores/game"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import toast from "react-hot-toast"
export default function Game() {
const router = useRouter()
const { player, logout } = usePlayerStore()
const { status, setStatus, resetStatus } = usePlayerGameStore()
useEffect(() => {
if (!player) {
router.replace("/")
}
}, [player, router])
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS) {
setStatus(name, data)
}
})
useEvent("game:reset", () => {
router.replace("/")
logout()
resetStatus()
toast("The game has been reset by the host")
})
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>{component}</GameWrapper>
}
+17
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
@@ -0,0 +1,112 @@
"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 { useManagerGameStore } from "@rahoot/web/stores/game"
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
import { useParams } from "next/navigation"
import { useEffect, useState } from "react"
export default function ManagerGame() {
const { socket } = useSocket()
const [nextText, setNextText] = useState("Start")
const { status, setStatus } = useManagerGameStore()
const { gameId }: { gameId?: string } = useParams()
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS_MANAGER) {
setStatus(name, data)
}
})
useEffect(() => {
if (status.name === "SHOW_START") {
setNextText("Start")
}
}, [status.name])
const handleSkip = () => {
setNextText("Skip")
switch (status.name) {
case Status.SHOW_ROOM:
socket?.emit("manager:startGame", { 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
}
}
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 textNext={nextText} onNext={handleSkip} manager>
{component}
</GameWrapper>
)
}
@@ -1,6 +1,9 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-primary: #ff9900;
--color-secondary: #1a140b;
}
.btn-shadow { .btn-shadow {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset; box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
+29
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",
}
export default function RootLayout({ children }: PropsWithChildren) {
return (
<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>
)
}
+10
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"
File renamed without changes.

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 380 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 704 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,11 +1,17 @@
import clsx from "clsx" import clsx from "clsx"
import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
type Props = PropsWithChildren &
ButtonHTMLAttributes<HTMLButtonElement> & {
icon: ElementType
}
export default function AnswerButton({ export default function AnswerButton({
className, className,
icon: Icon, icon: Icon,
children, children,
...otherProps ...otherProps
}) { }: Props) {
return ( return (
<button <button
className={clsx( className={clsx(
@@ -1,10 +1,12 @@
import clsx from "clsx" import clsx from "clsx"
import { ButtonHTMLAttributes, PropsWithChildren } from "react"
export default function Button({ children, className, ...otherProps }) { type Props = ButtonHTMLAttributes<HTMLButtonElement> & PropsWithChildren
export default function Button({ children, className, ...otherProps }: Props) {
return ( return (
<button <button
className={clsx( className={clsx(
"btn-shadow rounded-md bg-primary p-2 text-lg font-semibold text-white", "btn-shadow bg-primary rounded-md p-2 text-lg font-semibold text-white",
className, className,
)} )}
{...otherProps} {...otherProps}
@@ -1,4 +1,6 @@
export default function Form({ children }) { import { PropsWithChildren } from "react"
export default function Form({ children }: PropsWithChildren) {
return ( return (
<div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm"> <div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
{children} {children}
+21
View File
@@ -0,0 +1,21 @@
import clsx from "clsx"
import React from "react"
type Props = React.InputHTMLAttributes<HTMLInputElement>
export default function Input({
className,
type = "text",
...otherProps
}: Props) {
return (
<input
type={type}
className={clsx(
"rounded-sm p-2 text-lg font-semibold outline-2 outline-gray-300",
className,
)}
{...otherProps}
/>
)
}
@@ -1,4 +1,4 @@
import loader from "@/assets/loader.svg" import loader from "@rahoot/web/assets/loader.svg"
import Image from "next/image" import Image from "next/image"
export default function Loader() { export default function Loader() {
@@ -1,4 +1,6 @@
import { Toaster as ToasterRaw, ToastBar } from "react-hot-toast" "use client"
import { ToastBar, Toaster as ToasterRaw } from "react-hot-toast"
const Toaster = () => ( const Toaster = () => (
<ToasterRaw> <ToasterRaw>
@@ -0,0 +1,87 @@
"use client"
import { GameUpdateQuestion } from "@rahoot/common/types/game"
import background from "@rahoot/web/assets/background.webp"
import Button from "@rahoot/web/components/Button"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & {
textNext?: string
onNext?: () => void
manager?: boolean
}
export default function GameWrapper({
children,
textNext,
onNext,
manager,
}: Props) {
const { isConnected, connect } = useSocket()
const { player, logout } = usePlayerStore()
const router = useRouter()
const [questionState, setQuestionState] = useState<GameUpdateQuestion>()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:kick", () => {
logout()
router.replace("/")
})
useEvent("game:updateQuestion", ({ current, total }) => {
setQuestionState({
current,
total,
})
})
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>
<div className="flex w-full justify-between p-4">
{questionState && (
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
{`${questionState.current} / ${questionState.total}`}
</div>
)}
{manager && (
<Button
className="self-end bg-white px-4 !text-black"
onClick={onNext}
>
{textNext}
</Button>
)}
</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>
)
}
@@ -0,0 +1,52 @@
import logo from "@rahoot/web/assets/logo.svg"
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 Image from "next/image"
import { KeyboardEvent, useState } from "react"
import toast from "react-hot-toast"
type Props = {
// eslint-disable-next-line no-unused-vars
onSubmit: (password: string) => void
}
export default function 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 (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="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" />
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
</section>
)
}
@@ -0,0 +1,79 @@
import { Quizz } from "@rahoot/common/types/game"
import logo from "@rahoot/web/assets/logo.svg"
import Button from "@rahoot/web/components/Button"
import clsx from "clsx"
import Image from "next/image"
import { useState } from "react"
import toast from "react-hot-toast"
type QuizzWithId = Quizz & { id: string }
type Props = {
quizzList: QuizzWithId[]
// eslint-disable-next-line no-unused-vars
onSelect: (id: string) => void
}
export default function SelectQuizz({ quizzList, onSelect }: 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 (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="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" />
<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 flex-col items-center justify-center">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<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",
{
"border-primary outline-primary outline-2":
selected === quizz.id,
},
)}
onClick={handleSelect(quizz.id)}
>
{quizz.subject}
<div
className={clsx(
"h-4 w-4 rounded-sm outline-2 outline-gray-300",
selected === quizz.id && "bg-primary outline-primary/50",
)}
></div>
</button>
))}
</div>
</div>
<Button onClick={handleSubmit}>Submit</Button>
</div>
</section>
)
}
@@ -0,0 +1,37 @@
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"
export default function 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>
)
}
@@ -0,0 +1,44 @@
"use client"
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"
export default function Username() {
const { socket } = useSocket()
const { player, login } = usePlayerStore()
const router = useRouter()
const [username, setUsername] = useState("")
const handleLogin = () => {
socket?.emit("player:login", { gameId: player?.gameId, data: { username } })
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
handleLogin()
}
}
useEvent("game:successJoin", (gameId) => {
login(username)
router.replace(`/game/${gameId}`)
})
return (
<Form>
<Input
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Username here"
/>
<Button onClick={handleLogin}>Submit</Button>
</Form>
)
}
@@ -0,0 +1,114 @@
"use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
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"]
}
export default function Answers({
data: { question, answers, image, time, totalPlayer },
}: Props) {
const { gameId }: { gameId?: string } = useParams()
const { socket } = useSocket()
const { player } = usePlayerStore()
const [cooldown, setCooldown] = useState(time)
const [totalAnswer, setTotalAnswer] = useState(0)
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
volume: 0.1,
})
const [playMusic] = useSound(SFX_ANSWERS_MUSIC, {
volume: 0.2,
interrupt: true,
})
const handleAnswer = (answerKey: number) => () => {
if (!player) {
return
}
socket?.emit("player:selectedAnswer", {
gameId,
data: {
answerKey,
},
})
sfxPop()
}
useEffect(() => {
console.log("play music")
playMusic()
}, [])
useEvent("game:cooldown", (sec) => {
setCooldown(sec)
})
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>
{Boolean(image) && (
<img
alt={question}
src={image}
className="h-48 max-h-60 w-auto rounded-md"
/>
)}
</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>
</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>
)
}
@@ -1,4 +1,10 @@
export default function Leaderboard({ data: { leaderboard } }) { import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
type Props = {
data: ManagerStatusDataMap["SHOW_LEADERBOARD"]
}
export default function Leaderboard({ data: { leaderboard } }: Props) {
return ( return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center px-2"> <section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center px-2">
<h2 className="mb-6 text-5xl font-bold text-white drop-shadow-md"> <h2 className="mb-6 text-5xl font-bold text-white drop-shadow-md">
@@ -8,7 +14,7 @@ export default function Leaderboard({ data: { leaderboard } }) {
{leaderboard.map(({ username, points }, key) => ( {leaderboard.map(({ username, points }, key) => (
<div <div
key={key} key={key}
className="flex w-full justify-between rounded-md bg-primary p-3 text-2xl font-bold text-white" 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> <span className="drop-shadow-md">{username}</span>
<span className="drop-shadow-md">{points}</span> <span className="drop-shadow-md">{points}</span>
@@ -1,16 +1,23 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import useScreenSize from "@rahoot/web/hook/useScreenSize"
import { import {
SFX_PODIUM_FIRST, SFX_PODIUM_FIRST,
SFX_PODIUM_SECOND, SFX_PODIUM_SECOND,
SFX_PODIUM_THREE, SFX_PODIUM_THREE,
SFX_SNEAR_ROOL, SFX_SNEAR_ROOL,
} from "@/constants" } from "@rahoot/web/utils/constants"
import useScreenSize from "@/hook/useScreenSize"
import clsx from "clsx" import clsx from "clsx"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import ReactConfetti from "react-confetti" import ReactConfetti from "react-confetti"
import useSound from "use-sound" import useSound from "use-sound"
export default function Podium({ data: { subject, top } }) { type Props = {
data: ManagerStatusDataMap["FINISHED"]
}
export default function Podium({ data: { subject, top } }: Props) {
const [apparition, setApparition] = useState(0) const [apparition, setApparition] = useState(0)
const { width, height } = useScreenSize() const { width, height } = useScreenSize()
@@ -56,7 +63,7 @@ export default function Podium({ data: { subject, top } }) {
break break
} }
}, [apparition, sfxFirst, sfxSecond, sfxtThree, sfxRool]) }, [apparition, sfxFirst, sfxSecond, sfxtThree, sfxRool, sfxRoolStop])
useEffect(() => { useEffect(() => {
if (top.length < 3) { if (top.length < 3) {
@@ -77,7 +84,7 @@ export default function Podium({ data: { subject, top } }) {
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
return () => clearInterval(interval) return () => clearInterval(interval)
}, [apparition]) }, [apparition, top.length])
return ( return (
<> <>
@@ -90,8 +97,8 @@ export default function Podium({ data: { subject, top } }) {
)} )}
{apparition >= 3 && top.length >= 3 && ( {apparition >= 3 && top.length >= 3 && (
<div className="absolute min-h-screen w-full overflow-hidden"> <div className="pointer-events-none absolute min-h-screen w-full overflow-hidden">
<div className="spotlight"></div>{" "} <div className="spotlight"></div>
</div> </div>
)} )}
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between"> <section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between">
@@ -100,7 +107,8 @@ export default function Podium({ data: { subject, top } }) {
</h2> </h2>
<div <div
className={`grid w-full max-w-[800px] flex-1 grid-cols-${top.length} items-end justify-center justify-self-end overflow-y-hidden overflow-x-visible`} 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] && ( {top[1] && (
<div <div
@@ -111,7 +119,7 @@ export default function Podium({ data: { subject, top } }) {
> >
<p <p
className={clsx( className={clsx(
"overflow-visible whitespace-nowrap text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl", "overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{ {
"anim-balanced": apparition >= 4, "anim-balanced": apparition >= 4,
}, },
@@ -119,7 +127,7 @@ export default function Podium({ data: { subject, top } }) {
> >
{top[1].username} {top[1].username}
</p> </p>
<div className="flex h-full w-full flex-col items-center gap-4 rounded-t-md bg-primary pt-6 text-center shadow-2xl"> <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"> <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> <span className="drop-shadow-md">2</span>
</p> </p>
@@ -143,13 +151,13 @@ export default function Podium({ data: { subject, top } }) {
> >
<p <p
className={clsx( className={clsx(
"overflow-visible whitespace-nowrap text-center text-2xl font-bold text-white opacity-0 drop-shadow-lg md:text-4xl", "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 }, { "anim-balanced opacity-100": apparition >= 4 },
)} )}
> >
{top[0].username} {top[0].username}
</p> </p>
<div className="flex h-full w-full flex-col items-center gap-4 rounded-t-md bg-primary pt-6 text-center shadow-2xl"> <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"> <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> <span className="drop-shadow-md">1</span>
</p> </p>
@@ -170,7 +178,7 @@ export default function Podium({ data: { subject, top } }) {
> >
<p <p
className={clsx( className={clsx(
"overflow-visible whitespace-nowrap text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl", "overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
{ {
"anim-balanced": apparition >= 4, "anim-balanced": apparition >= 4,
}, },
@@ -178,7 +186,7 @@ export default function Podium({ data: { subject, top } }) {
> >
{top[2].username} {top[2].username}
</p> </p>
<div className="flex h-full w-full flex-col items-center gap-4 rounded-t-md bg-primary pt-6 text-center shadow-2xl"> <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"> <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> <span className="drop-shadow-md">3</span>
</p> </p>
@@ -1,8 +1,15 @@
import { ANSWERS_COLORS, ANSWERS_ICONS } from "@/constants" import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { ANSWERS_COLORS, ANSWERS_ICONS } from "@rahoot/web/utils/constants"
import clsx from "clsx" import clsx from "clsx"
import { createElement } from "react" import { createElement } from "react"
export default function Prepared({ data: { totalAnswers, questionNumber } }) { type Props = {
data: CommonStatusDataMap["SHOW_PREPARED"]
}
export default function Prepared({
data: { totalAnswers, questionNumber },
}: Props) {
return ( return (
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center"> <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"> <h2 className="anim-show mb-20 text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
@@ -1,8 +1,17 @@
import { SFX_SHOW_SOUND } from "@/constants" "use client"
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
import { useEffect } from "react" import { useEffect } from "react"
import useSound from "use-sound" import useSound from "use-sound"
export default function Question({ data: { question, image, cooldown } }) { type Props = {
data: CommonStatusDataMap["SHOW_QUESTION"]
}
export default function Question({
data: { question, image, cooldown },
}: Props) {
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 }) const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
useEffect(() => { useEffect(() => {
@@ -25,7 +34,7 @@ export default function Question({ data: { question, image, cooldown } }) {
)} )}
</div> </div>
<div <div
className="mb-20 h-4 self-start justify-self-end rounded-full bg-primary" className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
style={{ animation: `progressBar ${cooldown}s linear forwards` }} style={{ animation: `progressBar ${cooldown}s linear forwards` }}
></div> ></div>
</section> </section>
@@ -0,0 +1,125 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
SFX_ANSWERS_MUSIC,
SFX_RESULTS_SOUND,
} from "@rahoot/web/utils/constants"
import clsx from "clsx"
import { useEffect, useState } from "react"
import useSound from "use-sound"
import AnswerButton from "../../AnswerButton"
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
}
type Props = {
data: ManagerStatusDataMap["SHOW_RESPONSES"]
}
export default function Responses({
data: { question, answers, responses, correct },
}: Props) {
const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
const [playMusic, { stop: stopMusic }] = 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(() => {
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>
<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>
)
}
@@ -1,24 +1,28 @@
import CricleCheck from "@/components/icons/CricleCheck" "use client"
import CricleXmark from "@/components/icons/CricleXmark"
import { SFX_RESULTS_SOUND } from "@/constants" import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import { usePlayerContext } from "@/context/player" 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 { useEffect } from "react"
import useSound from "use-sound" import useSound from "use-sound"
type Props = {
data: CommonStatusDataMap["SHOW_RESULT"]
}
export default function Result({ export default function Result({
data: { correct, message, points, myPoints, rank, aheadOfMe }, data: { correct, message, points, myPoints, rank, aheadOfMe },
}) { }: Props) {
const { dispatch } = usePlayerContext() const player = usePlayerStore()
const [sfxResults] = useSound(SFX_RESULTS_SOUND, { const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2, volume: 0.2,
}) })
useEffect(() => { useEffect(() => {
dispatch({ player.updatePoints(myPoints)
type: "UPDATE",
payload: { points: myPoints },
})
sfxResults() sfxResults()
}, [sfxResults]) }, [sfxResults])
@@ -28,7 +32,7 @@ export default function Result({
{correct ? ( {correct ? (
<CricleCheck className="aspect-square max-h-60 w-full" /> <CricleCheck className="aspect-square max-h-60 w-full" />
) : ( ) : (
<CricleXmark 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"> <h2 className="mt-1 text-4xl font-bold text-white drop-shadow-lg">
{message} {message}
@@ -0,0 +1,70 @@
"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 { useState } from "react"
type Props = {
data: ManagerStatusDataMap["SHOW_ROOM"]
}
export default function Room({ data: { text, inviteCode } }: Props) {
const { socket } = useSocket()
const [playerList, setPlayerList] = useState<Player[]>([])
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) => () => {
socket?.emit("manager:kickPlayer", {
data: { 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>
)
}
@@ -1,11 +1,17 @@
import { SFX_BOUMP_SOUND } from "@/constants" "use client"
import { useSocketContext } from "@/context/socket"
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 clsx from "clsx"
import { useEffect, useState } from "react" import { useState } from "react"
import useSound from "use-sound" import useSound from "use-sound"
export default function Start({ data: { time, subject } }) { type Props = {
const { socket } = useSocketContext() data: CommonStatusDataMap["SHOW_START"]
}
export default function Start({ data: { time, subject } }: Props) {
const [showTitle, setShowTitle] = useState(true) const [showTitle, setShowTitle] = useState(true)
const [cooldown, setCooldown] = useState(time) const [cooldown, setCooldown] = useState(time)
@@ -13,22 +19,15 @@ export default function Start({ data: { time, subject } }) {
volume: 0.2, volume: 0.2,
}) })
useEffect(() => { useEvent("game:startCooldown", () => {
socket.on("game:startCooldown", () => { sfxBoump()
sfxBoump() setShowTitle(false)
setShowTitle(false) })
})
socket.on("game:cooldown", (sec) => { useEvent("game:cooldown", (sec) => {
sfxBoump() sfxBoump()
setCooldown(sec) setCooldown(sec)
}) })
return () => {
socket.off("game:startCooldown")
socket.off("game:cooldown")
}
}, [sfxBoump])
return ( return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center"> <section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
@@ -40,7 +39,7 @@ export default function Start({ data: { time, subject } }) {
<> <>
<div <div
className={clsx( className={clsx(
`anim-show aspect-square h-32 bg-primary transition-all md:h-60`, `anim-show bg-primary aspect-square h-32 transition-all md:h-60`,
)} )}
style={{ style={{
transform: `rotate(${45 * (time - cooldown)}deg)`, transform: `rotate(${45 * (time - cooldown)}deg)`,
@@ -1,6 +1,11 @@
import Loader from "@/components/Loader" import { PlayerStatusDataMap } from "@rahoot/common/types/game/status"
import Loader from "@rahoot/web/components/Loader"
export default function Wait({ data: { text } }) { type Props = {
data: PlayerStatusDataMap["WAIT"]
}
export default function Wait({ data: { text } }: Props) {
return ( return (
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center"> <section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
<Loader /> <Loader />
@@ -1,4 +1,9 @@
export default function Circle({ className, fill = "#FFF" }) { type Props = {
className?: string
fill?: string
}
export default function Circle({ className, fill = "#FFF" }: Props) {
return ( return (
<svg <svg
className={className} className={className}
@@ -1,4 +1,8 @@
export default function CricleCheck({ className }) { type Props = {
className?: string
}
export default function CricleCheck({ className }: Props) {
return ( return (
<svg <svg
fill="#22c55e" fill="#22c55e"
@@ -18,7 +22,7 @@ export default function CricleCheck({ className }) {
height="56.00" height="56.00"
rx="28" rx="28"
fill="#ffffff" fill="#ffffff"
strokewidth="0" strokeWidth="0"
/> />
</g> </g>
@@ -1,4 +1,8 @@
export default function CricleXmark({ className }) { type Props = {
className?: string
}
export default function CricleXmark({ className }: Props) {
return ( return (
<svg <svg
fill="#ef4444" fill="#ef4444"
@@ -17,7 +21,7 @@ export default function CricleXmark({ className }) {
height="56.00" height="56.00"
rx="28" rx="28"
fill="#ffffff" fill="#ffffff"
strokewidth="0" strokeWidth="0"
/> />
</g> </g>
@@ -1,4 +1,10 @@
export default function Pentagon({ className, fill, stroke }) { type Props = {
className?: string
fill?: string
stroke?: string
}
export default function Pentagon({ className, fill, stroke }: Props) {
return ( return (
<svg <svg
className={className} className={className}
@@ -1,4 +1,9 @@
export default function Rhombus({ className, fill = "#FFF" }) { type Props = {
className?: string
fill?: string
}
export default function Rhombus({ className, fill = "#FFF" }: Props) {
return ( return (
<svg <svg
className={className} className={className}
@@ -1,4 +1,9 @@
export default function Square({ className, fill = "#FFF" }) { type Props = {
className?: string
fill?: string
}
export default function Square({ className, fill = "#FFF" }: Props) {
return ( return (
<svg <svg
className={className} className={className}
@@ -1,4 +1,9 @@
export default function Triangle({ className, fill = "#FFF" }) { type Props = {
className?: string
fill?: string
}
export default function Triangle({ className, fill = "#FFF" }: Props) {
return ( return (
<svg <svg
className={className} className={className}
@@ -0,0 +1,140 @@
/* 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"
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>
interface SocketContextValue {
socket: TypedSocket | null
isConnected: boolean
connect: () => void
disconnect: () => void
reconnect: () => void
}
const SocketContext = createContext<SocketContextValue>({
socket: null,
isConnected: false,
connect: () => {},
disconnect: () => {},
reconnect: () => {},
})
const getSocketServer = async () => {
const res = await ky.get("/socket").json<{ url: string }>()
return res.url
}
export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const [socket, setSocket] = useState<TypedSocket | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
let s: TypedSocket | null = null
const initSocket = async () => {
try {
const socketUrl = await getSocketServer()
s = io(socketUrl, {
transports: ["websocket"],
autoConnect: false,
})
setSocket(s)
s.on("connect", () => {
setIsConnected(true)
})
s.on("disconnect", () => {
console.log("Socket disconnected")
setIsConnected(false)
})
s.on("connect_error", (err) => {
console.error("Connection error:", err.message)
})
} catch (error) {
console.error("Failed to initialize socket:", error)
}
}
initSocket()
return () => {
s?.disconnect()
}
}, [])
const connect = useCallback(() => {
if (socket && !socket.connected) {
console.log("🔌 Manual connect")
socket.connect()
}
}, [socket])
const disconnect = useCallback(() => {
if (socket && socket.connected) {
console.log("🧹 Manual disconnect")
socket.disconnect()
}
}, [socket])
const reconnect = useCallback(() => {
if (socket) {
console.log("♻️ Manual reconnect")
socket.disconnect()
socket.connect()
}
}, [socket])
return (
<SocketContext.Provider
value={{
socket,
isConnected,
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
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
File renamed without changes.
+44
View File
@@ -0,0 +1,44 @@
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { create } from "zustand"
export type Status<T> = {
[K in keyof T]: { name: K; data: T[K] }
}[keyof T]
export function createStatus<T, K extends keyof T>(
name: K,
data: T[K],
): Status<T> {
return { name, data }
}
type GameStore<T> = {
status: Status<T>
// eslint-disable-next-line no-unused-vars
setStatus: <K extends keyof T>(name: K, data: T[K]) => void
resetStatus: () => void
}
export const usePlayerGameStore = create<GameStore<StatusDataMap>>((set) => {
const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", {
text: "Waiting for the players",
})
return {
status: initialStatus,
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }),
}
})
export const useManagerGameStore = create<GameStore<StatusDataMap>>((set) => {
const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", {
text: "Waiting for the players",
})
return {
status: initialStatus,
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }),
}
})
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable no-unused-vars */
import { create } from "zustand"
type PlayerState = {
gameId?: string
username?: string
points?: number
}
type PlayerStore = {
player: PlayerState | null
login: (gameId: string) => void
join: (username: string) => void
updatePoints: (points: number) => void
logout: () => void
}
export const usePlayerStore = create<PlayerStore>((set) => ({
player: null,
login: (username) =>
set((state) => ({
player: { ...state.player, username },
})),
join: (gameId) =>
set((state) => ({
player: { ...state.player, gameId, points: 0 },
})),
updatePoints: (points) =>
set((state) => ({
player: { ...state.player, points },
})),
logout: () => set({ player: null }),
}))
+63
View File
@@ -0,0 +1,63 @@
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"
+31
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"]
}
+4911
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- "packages/*"
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-74
View File
@@ -1,74 +0,0 @@
import { Server } from "socket.io"
import { GAME_STATE_INIT, WEBSOCKET_SERVER_PORT } from "../config.mjs"
import Manager from "./roles/manager.js"
import Player from "./roles/player.js"
import { abortCooldown } from "./utils/cooldown.js"
import deepClone from "./utils/deepClone.js"
let gameState = deepClone(GAME_STATE_INIT)
const io = new Server({
cors: {
origin: "*",
},
})
console.log(`Server running on port ${WEBSOCKET_SERVER_PORT}`)
io.listen(WEBSOCKET_SERVER_PORT)
io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`)
socket.on("player:checkRoom", (roomId) =>
Player.checkRoom(gameState, io, socket, roomId),
)
socket.on("player:join", (player) =>
Player.join(gameState, io, socket, player),
)
socket.on("manager:createRoom", (password) =>
Manager.createRoom(gameState, io, socket, password),
)
socket.on("manager:kickPlayer", (playerId) =>
Manager.kickPlayer(gameState, io, socket, playerId),
)
socket.on("manager:startGame", () => Manager.startGame(gameState, io, socket))
socket.on("player:selectedAnswer", (answerKey) =>
Player.selectedAnswer(gameState, io, socket, answerKey),
)
socket.on("manager:abortQuiz", () => Manager.abortQuiz(gameState, io, socket))
socket.on("manager:nextQuestion", () =>
Manager.nextQuestion(gameState, io, socket),
)
socket.on("manager:showLeaderboard", () =>
Manager.showLoaderboard(gameState, io, socket),
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
if (gameState.manager === socket.id) {
console.log("Reset game")
io.to(gameState.room).emit("game:reset")
gameState.started = false
gameState = deepClone(GAME_STATE_INIT)
abortCooldown()
return
}
const player = gameState.players.find((p) => p.id === socket.id)
if (player) {
gameState.players = gameState.players.filter((p) => p.id !== socket.id)
socket.to(gameState.manager).emit("manager:removePlayer", player.id)
}
})
})
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}
-123
View File
@@ -1,123 +0,0 @@
import { GAME_STATE_INIT } from "../../config.mjs"
import { abortCooldown, cooldown, sleep } from "../utils/cooldown.js"
import deepClone from "../utils/deepClone.js"
import generateRoomId from "../utils/generateRoomId.js"
import { startRound } from "../utils/round.js"
const Manager = {
createRoom: (game, io, socket, password) => {
if (game.password !== password) {
io.to(socket.id).emit("game:errorMessage", "Bad Password")
return
}
if (game.manager || game.room) {
io.to(socket.id).emit("game:errorMessage", "Already manager")
return
}
const roomInvite = generateRoomId()
game.room = roomInvite
game.manager = socket.id
socket.join(roomInvite)
io.to(socket.id).emit("manager:inviteCode", roomInvite)
console.log(`New room created: ${roomInvite}`)
},
kickPlayer: (game, io, socket, playerId) => {
if (game.manager !== socket.id) {
return
}
const player = game.players.find((p) => p.id === playerId)
game.players = game.players.filter((p) => p.id !== playerId)
io.in(playerId).socketsLeave(game.room)
io.to(player.id).emit("game:kick")
io.to(game.manager).emit("manager:playerKicked", player.id)
io.to(game.room).emit("game:totalPlayers", game.players.length)
},
startGame: async (game, io, socket) => {
if (game.started || !game.room) {
return
}
game.started = true
io.to(game.room).emit("game:status", {
name: "SHOW_START",
data: {
time: 3,
subject: game.subject,
},
})
await sleep(3)
io.to(game.room).emit("game:startCooldown")
await cooldown(3, io, game.room)
startRound(game, io, socket)
},
nextQuestion: (game, io, socket) => {
if (!game.started) {
return
}
if (socket.id !== game.manager) {
return
}
if (!game.questions[game.currentQuestion + 1]) {
return
}
game.currentQuestion += 1
startRound(game, io, socket)
},
abortQuiz: (game, io, socket) => {
if (!game.started) {
return
}
if (socket.id !== game.manager) {
return
}
abortCooldown(game, io, game.room)
},
showLoaderboard: (game, io, socket) => {
if (!game.questions[game.currentQuestion + 1]) {
socket.emit("game:status", {
name: "FINISH",
data: {
subject: game.subject,
top: game.players.slice(0, 3).sort((a, b) => b.points - a.points),
},
})
// eslint-disable-next-line no-param-reassign
game = deepClone(GAME_STATE_INIT)
return
}
socket.emit("game:status", {
name: "SHOW_LEADERBOARD",
data: {
leaderboard: game.players
.sort((a, b) => b.points - a.points)
.slice(0, 5),
},
})
},
}
export default Manager
-104
View File
@@ -1,104 +0,0 @@
import convertTimeToPoint from "../utils/convertTimeToPoint.js"
import { abortCooldown } from "../utils/cooldown.js"
import { inviteCodeValidator, usernameValidator } from "../validator.js"
const Player = {
checkRoom: async (game, io, socket, roomId) => {
try {
await inviteCodeValidator.validate(roomId)
} catch (error) {
socket.emit("game:errorMessage", error.errors[0])
return
}
if (!game.room || roomId !== game.room) {
socket.emit("game:errorMessage", "Room not found")
return
}
socket.emit("game:successRoom", roomId)
},
join: async (game, io, socket, player) => {
try {
await usernameValidator.validate(player.username)
} catch (error) {
socket.emit("game:errorMessage", error.errors[0])
return
}
if (!game.room || player.room !== game.room) {
socket.emit("game:errorMessage", "Room not found")
return
}
if (game.players.find((p) => p.username === player.username)) {
socket.emit("game:errorMessage", "Username already exists")
return
}
if (game.started) {
socket.emit("game:errorMessage", "Game already started")
return
}
console.log("New Player", player)
socket.join(player.room)
const playerData = {
username: player.username,
room: player.room,
id: socket.id,
points: 0,
}
socket.to(player.room).emit("manager:newPlayer", { ...playerData })
game.players.push(playerData)
// Emit total players to all players in the room
io.to(player.room).emit("game:totalPlayers", game.players.length)
socket.emit("game:successJoin")
},
selectedAnswer: (game, io, socket, answerKey) => {
const player = game.players.find((player) => player.id === socket.id)
const question = game.questions[game.currentQuestion]
if (!player) {
return
}
if (game.playersAnswer.find((p) => p.id === socket.id)) {
return
}
game.playersAnswer.push({
id: socket.id,
answer: answerKey,
points: convertTimeToPoint(game.roundStartTime, question.time),
})
socket.emit("game:status", {
name: "WAIT",
data: { text: "Waiting for the players to answer" },
})
socket.to(game.room).emit("game:playerAnswer", game.playersAnswer.length)
io.to(game.room).emit("game:totalPlayers", game.players.length)
if (game.playersAnswer.length === game.players.length) {
abortCooldown()
}
},
}
export default Player
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more