Merge pull request #25 from Ralex91/v1.0.0

V1.0.0
This commit is contained in:
Ralex authored and GitHub committed 2025-10-29 19:36:47 +01:00
commit 91cc356564
149 files changed
+9192 -8852

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
.pnpm-store
.git
+2
View File
@@ -0,0 +1,2 @@
WEB_ORIGIN=http://localhost:3000 # Default: http://localhost:3000, for allow all origins use '*'
SOCKET_URL=http://localhost:3001 # Default: http://localhost:3001
+4 -1
View File
@@ -1 +1,4 @@
<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.039999961853027 -54.5099983215332 358.2699890136719 56.39999771118164"><g fill="#ff9900"><path d="M55.56 -35.24C55.56 -38.05 54.92 -40.7 53.64 -43.2C52.35 -45.7 50.52 -47.69 48.14 -49.19C47.16 -49.8 46.05 -50.29 44.81 -50.66C43.57 -51.03 42.29 -51.33 40.96 -51.54C39.62 -51.75 38.3 -51.87 37 -51.92C35.69 -51.97 34.47 -51.99 33.35 -51.99C28.96 -51.99 24.58 -51.85 20.21 -51.57C15.85 -51.29 11.47 -51.03 7.08 -50.8C6.8 -43.23 6.59 -35.67 6.45 -28.1C6.4 -23.85 6.24 -19.58 5.96 -15.31C5.68 -11.04 5.37 -6.75 5.04 -2.45C6.35 -2.31 7.65 -2.22 8.93 -2.17C10.22 -2.13 11.54 -2.1 12.89 -2.1C15.56 -2.1 18.22 -2.17 20.88 -2.31L20.53 -18.22C22.03 -18.31 23.53 -18.43 25.05 -18.57C26.57 -18.71 28.1 -18.83 29.64 -18.92C30.99 -15.42 32.04 -12.08 32.79 -8.9C33.54 -5.72 34.1 -2.24 34.47 1.54L54.93 1.05C51.48 -7.03 47.44 -14.92 42.81 -22.63C46.64 -22.82 49.73 -24.01 52.06 -26.21C54.4 -28.4 55.56 -31.41 55.56 -35.24ZM39.31 -36.37C39.31 -36.13 39.3 -35.74 39.27 -35.17C39.25 -34.61 39.23 -34.04 39.2 -33.46C39.18 -32.87 39.12 -32.31 39.03 -31.78C38.94 -31.24 38.82 -30.88 38.68 -30.69C38.35 -30.27 37.78 -29.93 36.96 -29.67C36.14 -29.42 35.26 -29.22 34.3 -29.08C33.34 -28.94 32.41 -28.85 31.5 -28.8C30.59 -28.75 29.9 -28.73 29.43 -28.73C28.35 -28.73 27.26 -28.78 26.14 -28.87C25.01 -28.96 23.94 -29.13 22.91 -29.36L21.02 -42.95C22.8 -43.23 24.58 -43.47 26.38 -43.65C28.18 -43.84 30.01 -43.93 31.88 -43.93C32.12 -43.93 32.52 -43.92 33.11 -43.9C33.69 -43.87 34.3 -43.83 34.93 -43.76C35.56 -43.69 36.16 -43.61 36.72 -43.51C37.28 -43.42 37.67 -43.28 37.91 -43.09C38.23 -42.81 38.49 -42.38 38.68 -41.8C38.87 -41.21 39.01 -40.58 39.1 -39.9C39.19 -39.23 39.25 -38.56 39.27 -37.91C39.3 -37.25 39.31 -36.74 39.31 -36.37Z M111.69 -31.6L111.69 -32.3L111.69 -33.39C111.69 -33.78 111.68 -34.15 111.66 -34.47C111.63 -34.8 111.6 -35.03 111.55 -35.17C111.18 -36.48 110.57 -37.71 109.73 -38.85C108.89 -40 107.91 -41.01 106.79 -41.9C105.66 -42.79 104.46 -43.54 103.18 -44.14C101.89 -44.75 100.6 -45.17 99.29 -45.4C98.03 -45.64 96.66 -45.83 95.19 -45.97C93.72 -46.11 92.23 -46.21 90.74 -46.28C89.24 -46.35 87.75 -46.4 86.26 -46.42C84.76 -46.44 83.38 -46.46 82.12 -46.46C78.62 -46.46 75.15 -46.33 71.72 -46.07C68.28 -45.81 64.86 -45.38 61.45 -44.77C61.54 -43 61.53 -41.2 61.42 -39.38C61.3 -37.56 61.15 -35.76 60.96 -33.98C63.9 -34.08 66.85 -34.18 69.79 -34.3C72.73 -34.42 75.67 -34.47 78.62 -34.47L80.65 -34.47L83.87 -34.47C85.09 -34.47 86.34 -34.44 87.62 -34.37C88.91 -34.3 90.1 -34.22 91.19 -34.12C92.29 -34.03 93.22 -33.89 93.96 -33.7C94.71 -33.52 95.15 -33.28 95.29 -33C95.34 -32.91 95.36 -32.66 95.36 -32.27L95.36 -31.53C95.36 -30.55 95.29 -29.55 95.15 -28.52L91.44 -28.52C89.71 -28.52 87.75 -28.48 85.55 -28.41C83.36 -28.34 81.06 -28.24 78.65 -28.1C76.25 -27.96 74 -27.77 71.93 -27.54C69.85 -27.3 68.06 -26.99 66.57 -26.59C65.07 -26.19 64.11 -25.72 63.69 -25.15C63.37 -24.73 63.09 -24.08 62.85 -23.19C62.62 -22.31 62.43 -21.35 62.29 -20.32C62.15 -19.29 62.06 -18.31 62.01 -17.38C61.96 -16.44 61.94 -15.7 61.94 -15.13C61.94 -13.5 62.24 -12.04 62.85 -10.76C63.46 -9.47 64.53 -8.41 66.08 -7.57C66.5 -7.33 67.34 -7.16 68.6 -7.04C69.86 -6.93 71.19 -6.83 72.59 -6.76C73.99 -6.69 75.32 -6.64 76.59 -6.62C77.85 -6.6 78.71 -6.59 79.18 -6.59C84.41 -6.59 89.62 -6.75 94.8 -7.08C99.99 -7.4 105.2 -7.8 110.43 -8.27L111.27 -19.9C111.55 -23.78 111.69 -27.68 111.69 -31.6ZM96.06 -15.13C94.99 -14.99 93.82 -14.84 92.56 -14.68C91.3 -14.52 90 -14.38 88.67 -14.26C87.34 -14.14 86.03 -14.04 84.75 -13.94C83.46 -13.85 82.26 -13.8 81.14 -13.8C80.86 -13.8 80.46 -13.82 79.95 -13.84C79.44 -13.86 78.92 -13.91 78.41 -13.98C77.89 -14.05 77.41 -14.14 76.97 -14.26C76.53 -14.38 76.21 -14.55 76.03 -14.78C75.79 -15.02 75.6 -15.39 75.46 -15.91C75.32 -16.42 75.2 -16.98 75.08 -17.59C74.96 -18.19 74.88 -18.78 74.83 -19.34C74.79 -19.9 74.76 -20.34 74.76 -20.67C74.76 -21.09 74.8 -21.5 74.87 -21.9C74.94 -22.29 75.09 -22.68 75.32 -23.05C75.51 -23.33 76.21 -23.54 77.43 -23.68C78.64 -23.82 79.98 -23.94 81.46 -24.03C82.93 -24.13 84.3 -24.19 85.59 -24.21C86.87 -24.23 87.66 -24.24 87.94 -24.24C89.29 -24.24 90.65 -24.23 92 -24.21C93.36 -24.19 94.71 -24.15 96.06 -24.1Z M166.27 -27.33L166.27 -27.61C166.27 -27.7 166.25 -27.79 166.2 -27.89C165.69 -29.99 164.69 -31.62 163.19 -32.79C161.7 -33.96 159.98 -34.81 158.04 -35.35C156.1 -35.89 154.07 -36.21 151.94 -36.33C149.82 -36.45 147.92 -36.51 146.23 -36.51L142.73 -36.51C141.52 -36.51 140.31 -36.44 139.12 -36.3C137.93 -36.16 136.77 -35.89 135.65 -35.49C134.53 -35.09 133.53 -34.52 132.64 -33.77C132.73 -36.86 132.83 -39.93 132.92 -42.99C133.01 -46.05 133.11 -49.12 133.2 -52.2L117.79 -51.22C118.21 -42.81 118.65 -34.44 119.12 -26.1C119.58 -17.76 120.08 -9.39 120.59 -0.98L130.47 -0.49C130.61 -4.32 130.95 -8.1 131.48 -11.84C132.02 -15.58 132.48 -19.34 132.85 -23.12C133.55 -23.5 134.43 -23.86 135.48 -24.21C136.53 -24.56 137.64 -24.87 138.81 -25.1Line truncated <svg width="693" height="133" viewBox="0 0 693 133" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M494.894 100.866L414.895 117.87L418.111 133L498.11 115.996L494.894 100.866Z" fill="#FF9900"/>
<path d="M97.6782 37.2577C97.6782 31.8247 96.4408 26.701 93.966 21.8674C91.4718 17.0337 87.9336 13.1862 83.332 10.286C81.4372 9.10658 79.291 8.15918 76.8935 7.4438C74.4961 6.72842 72.0212 6.14839 69.4497 5.74236C66.8589 5.33633 64.3067 5.10432 61.7933 5.00765C59.2604 4.91098 56.9016 4.8723 54.7361 4.8723C46.2483 4.8723 37.7797 5.14299 29.3305 5.68436C20.9007 6.22573 12.4321 6.72843 3.94425 7.17312C3.40288 21.8094 2.99686 36.4263 2.72617 51.0626C2.6295 59.2798 2.32015 67.5356 1.77878 75.7915C1.23741 84.0473 0.638041 92.3419 0 100.656C2.53283 100.926 5.04632 101.1 7.52114 101.197C10.0153 101.274 12.5675 101.332 15.1776 101.332C20.34 101.332 25.4829 101.197 30.6259 100.926L29.9492 70.1651C32.8494 69.9911 35.7496 69.7591 38.6885 69.4884C41.6273 69.2177 44.5855 68.9857 47.563 68.8117C50.1732 75.5788 52.2033 82.0365 53.6534 88.1849C55.1035 94.3333 56.1862 101.062 56.9016 108.37L96.4601 107.423C89.7897 91.8005 81.9785 76.5455 73.0266 61.6386C80.4318 61.2712 86.4062 58.9704 90.9111 54.7168C95.4354 50.4825 97.6782 44.6628 97.6782 37.2577ZM66.2595 35.0729C66.2595 35.5369 66.2402 36.291 66.1822 37.393C66.1435 38.4758 66.1049 39.5778 66.0469 40.6992C66.0082 41.84 65.8922 42.9227 65.7182 43.9475C65.5442 44.9915 65.3121 45.6876 65.0415 46.0549C64.4034 46.867 63.3013 47.5243 61.7159 48.027C60.1305 48.5104 58.429 48.8971 56.5729 49.1678C54.7168 49.4385 52.9187 49.6125 51.1592 49.7092C49.3998 49.8058 48.0657 49.8445 47.157 49.8445C45.0689 49.8445 42.9614 49.7478 40.7959 49.5738C38.6111 49.3998 36.5423 49.0711 34.5509 48.6264L30.8966 22.3507C34.3382 21.8094 37.7797 21.3453 41.26 20.9973C44.7402 20.63 48.2784 20.456 51.894 20.456C52.358 20.456 53.1314 20.4753 54.2721 20.514C55.3935 20.572 56.5729 20.6493 57.791 20.7847C59.0091 20.92 60.1692 21.0747 61.2519 21.268C62.3346 21.442 63.0887 21.7127 63.5527 22.0801C64.1714 22.6214 64.6741 23.4528 65.0415 24.5742C65.4088 25.715 65.6795 26.933 65.8535 28.2478C66.0275 29.5432 66.1435 30.8386 66.1822 32.0954C66.2402 33.3714 66.2595 34.3575 66.2595 35.0729ZM206.203 44.2955V42.9421V40.8346C206.203 40.0805 206.184 39.3652 206.145 38.7465C206.087 38.1084 206.029 37.6637 205.932 37.393C205.217 34.8602 204.038 32.4821 202.414 30.2779C200.789 28.0544 198.895 26.1017 196.729 24.3809C194.544 22.6601 192.224 21.21 189.749 20.0499C187.255 18.8705 184.761 18.0585 182.228 17.6138C179.792 17.1497 177.143 16.7824 174.301 16.5117C171.459 16.241 168.578 16.0477 165.697 15.9123C162.797 15.777 159.916 15.6803 157.035 15.6417C154.135 15.603 151.467 15.5643 149.031 15.5643C142.264 15.5643 135.555 15.8157 128.923 16.3184C122.272 16.8211 115.659 17.6525 109.066 18.8319C109.24 22.2541 109.221 25.7343 109.008 29.2532C108.776 32.7721 108.486 36.2523 108.119 39.6939C113.803 39.5005 119.507 39.3072 125.191 39.0751C130.876 38.8431 136.56 38.7465 142.264 38.7465H146.189H152.414C154.773 38.7465 157.19 38.8045 159.665 38.9398C162.159 39.0751 164.46 39.2298 166.567 39.4232C168.694 39.5972 170.492 39.8679 171.923 40.2352C173.373 40.5832 174.224 41.0473 174.494 41.5886C174.591 41.7626 174.63 42.246 174.63 43.0001V44.4308C174.63 46.3256 174.494 48.2591 174.224 50.2505H167.051C163.706 50.2505 159.916 50.3279 155.663 50.4632C151.428 50.5985 146.981 50.7919 142.322 51.0626C137.681 51.3333 133.331 51.7006 129.329 52.1453C125.307 52.6093 121.846 53.2087 118.966 53.9821C116.065 54.7555 114.209 55.6642 113.397 56.7663C112.778 57.5783 112.237 58.8351 111.773 60.5558C111.328 62.2573 110.961 64.1134 110.69 66.1049C110.42 68.0963 110.246 69.9911 110.149 71.7892C110.052 73.6067 110.014 75.0374 110.014 76.1395C110.014 79.291 110.594 82.1139 111.773 84.5887C112.952 87.0829 115.021 89.1323 118.018 90.7564C118.83 91.2205 120.454 91.5491 122.89 91.7812C125.327 91.9938 127.898 92.1872 130.605 92.3225C133.312 92.4579 135.883 92.5545 138.339 92.5932C140.775 92.6319 142.438 92.6512 143.346 92.6512C153.458 92.6512 163.532 92.3419 173.547 91.7038C183.582 91.0851 193.655 90.3117 203.767 89.403L205.391 66.9169C205.932 59.4151 206.203 51.8746 206.203 44.2955ZM175.983 76.1395C173.914 76.4102 171.652 76.7002 169.216 77.0096C166.78 77.3189 164.266 77.5896 161.695 77.8216C159.123 78.0536 156.591 78.247 154.116 78.4403C151.622 78.6143 149.301 78.711 147.136 78.711C146.595 78.711 145.821 78.6723 144.835 78.6337C143.849 78.595 142.844 78.4983 141.858 78.363C140.852 78.2276 139.924 78.0536 139.074 77.8216C138.223 77.5896 137.604 77.2609 137.256 76.8162C136.792 76.3522 136.425 75.6368 136.154 74.6314C135.883 73.6453 135.651 72.5626 135.419 71.3832C135.187 70.2231 135.033 69.0824 134.936 67.9996C134.859 66.9169 134.801 66.0662 134.801 65.4281C134.801 64.6161 134.878 63.8234 135.013 63.05C135.149 62.296 135.439 61.5419 135.883 60.8265C136.251 60.2852 137.604 59.8791 139.963 59.6084C142.302 59.3378 144.893 59.1057 147.755 58.9317C150.597 58.7384 153.246 58.6224 155.74 58.5837C158.215 58.545 159.742 58.5257 160.284 58.5257C162.894 58.5257 165.523 58.545 168.133 58.5837C170.763 58.6224 173.373 58.6997 175.983 58.7964V76.1395ZM311.731 52.5513V52.01C311.731 51.Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

+69
View File
@@ -0,0 +1,69 @@
name: Docker Release
on:
release:
types: [published]
env:
DOCKER_PLATFORMS: |
linux/amd64
linux/arm64
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version and tags
run: |
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
VERSION=${GITHUB_REF_NAME#v}
echo "VERSION=$VERSION" >> $GITHUB_ENV
# Extract major.minor (ex: 1.2.3 -> 1.2)
MAJOR_MINOR=$(echo $VERSION | cut -d. -f1,2)
echo "MAJOR_MINOR=$MAJOR_MINOR" >> $GITHUB_ENV
else
echo "VERSION=latest" >> $GITHUB_ENV
echo "MAJOR_MINOR=latest" >> $GITHUB_ENV
fi
- name: Extract repository name (lowercase)
run: echo "REPO_NAME=$(basename ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ env.DOCKER_PLATFORMS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
install: true
platforms: ${{ env.DOCKER_PLATFORMS }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ env.DOCKER_PLATFORMS }}
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.REPO_NAME }}:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.REPO_NAME }}:${{ env.MAJOR_MINOR }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.REPO_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
+3 -36
View File
@@ -1,37 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules /node_modules
/socket/node_modules .env
/.pnp .secrets
.pnp.js release.json
.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",
+73
View File
@@ -0,0 +1,73 @@
FROM node:22-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
ENV NEXT_TELEMETRY_DISABLED=1
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:22-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
# Copy the game default config
COPY --from=builder /app/config ./config
# Expose the web and socket ports
EXPOSE 3000 5505
# Environment variables
ENV NODE_ENV=production
ENV CONFIG_PATH=/app/config
# Start both services (Next.js web app + Socket server)
CMD ["sh", "-c", "node packages/web/server.js & node packages/socket/dist/index.cjs"]
+138 -70
View File
@@ -1,7 +1,10 @@
<p align="center"> <p align="center">
<img width="450" height="120" align="center" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/logo.svg"> <img width="450" height="120" align="center" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/logo.svg">
<br> <br>
<img align="center" src="https://api.visitorbadge.io/api/visitors?path=https://github.com/Ralex91/Rahoot/edit/main/README.md&countColor=%2337d67a"> <div align="center">
<img alt="Visitor Badge" src="https://api.visitorbadge.io/api/visitors?path=https://github.com/Ralex91/Rahoot/edit/main/README.md&countColor=%2337d67a">
<img src="https://img.shields.io/docker/pulls/ralex91/rahoot?style=for-the-badge&logo=docker&color=2496ED" alt="Docker Pulls">
</div>
</p> </p>
## 🧩 What is this project? ## 🧩 What is this project?
@@ -10,95 +13,160 @@ Rahoot is a straightforward and open-source clone of the Kahoot! platform, allow
> ⚠️ This project is still under development, please report any bugs or suggestions in the [issues](https://github.com/Ralex91/Rahoot/issues) > ⚠️ This project is still under development, please report any bugs or suggestions in the [issues](https://github.com/Ralex91/Rahoot/issues)
<p align="center">
<img width="30%" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/preview1.jpg" alt="Login">
<img width="30%" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/preview2.jpg" alt="Manager Dashboard">
<img width="30%" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/preview3.jpg" alt="Question Screen">
</p>
## ⚙️ Prerequisites ## ⚙️ Prerequisites
- Node.js version 20 or higher Choose one of the following deployment methods:
### Without Docker
- Node.js : version 20 or higher
- PNPM : Learn more about [here](https://pnpm.io/)
### With Docker
- Docker and Docker Compose
## 📖 Getting Started ## 📖 Getting Started
1. #### Clone the GitHub repository of your project. Choose your deployment method:
```bash
git clone https://github.com/Ralex91/Rahoot.git
cd ./Rahoot
```
2. #### Install the dependencies using your preferred package manager
```bash ### 🐳 Using Docker (Recommended)
npm install
```
<br> Using Docker Compose (recommended):
<hr> You can find the docker compose configuration in the repository:
[docker-compose.yml](/compose.yml)
## 📦 Running the Application in Production Mode:
1. #### Check websocket connfiguration in [config.mjs](config.mjs)
If you want the client to connect directly to the websocket server, edit the [config.mjs](config.mjs) file and change the localhost to your public IP address.
```js
export const WEBSOCKET_PUBLIC_URL = "http://1.2.3.4:3100/"
export const WEBSOCKET_SERVER_PORT = 3100
// Rest of the config ...
```
2. #### Start the application
```bash
npm run all
```
## ⚙️ Running the Application in Development Mode:
```bash ```bash
npm run all-dev docker compose up -d
``` ```
## 🔧 Configuration Or using Docker directly:
Configuration can be found in [config.mjs](config.mjs) ```bash
docker run -d \
-p 3000:3000 \
-p 3001:3001 \
-v ./config:/app/config \
-e WEB_ORIGIN=http://localhost:3000 \
-e SOCKET_URL=http://localhost:3001 \
ralex91/rahoot:latest
```
```js **Configuration Volume:**
const QUIZZ_CONFIG = { The `-v ./config:/app/config` option mounts a local `config` folder to persist your game settings and quizzes. This allows you to:
password: "PASSWORD", // Manager password
subject: "Adobe", // Subject of the quiz - Edit your configuration files directly on your host machine
questions: [ - Keep your settings when updating the container
{ // Example question - Easily backup your quizzes and game configuration
question: "What is good answer ?", // Question
answers: [ // Possible answers The folder will be created automatically on first run with an example quiz to get you started.
"No",
"Yes", The application will be available at:
"No",
"No", - Web Interface: http://localhost:3000
], - WebSocket Server: ws://localhost:3001
image:
"https://images.unsplash.com/....", // Image URL (optional) ### 🛠️ Without Docker
solution: 1, // Index of the correct answer (index starts at 0)
cooldown: 5, // Show question cooldown in seconds 1. Clone the repository:
time: 15, // Time to answer in seconds
}, ```bash
... git clone https://github.com/Ralex91/Rahoot.git
], cd ./Rahoot
```
2. Install dependencies:
```bash
pnpm install
```
3. Change the environment variables in the `.env` file
4. Build and start the application:
```bash
# Development mode
pnpm run dev
# Production mode
pnpm run build
pnpm start
```
## ⚙️ Configuration
The configuration is split into two main parts:
### 1. Game Configuration (`config/game.json`)
Main game settings:
```json
{
"managerPassword": "PASSWORD",
"music": true
} }
``` ```
## 🤔 How to use Options:
- Go to [https://localhost:3000/manager](https://localhost:3000/manager) enter manager password. - `managerPassword`: The master password for accessing the manager interface
- `music`: Enable/disable game music
- Share link [https://localhost:3000/](https://localhost:3000/) and code on manager screen with your friends and get ready to play. ### 2. Quiz Configuration (`config/quizz/*.json`)
- Once everyone is ready, start the game with button on the top left of the screen of manager. Create your quiz files in the `config/quizz/` directory. You can have multiple quiz files and select which one to use when starting a game.
Example quiz configuration (`config/quizz/example.json`):
```json
{
"subject": "Example Quiz",
"questions": [
{
"question": "What is the correct answer?",
"answers": ["No", "Yes", "No", "No"],
"image": "https://images.unsplash.com/....",
"solution": 1,
"cooldown": 5,
"time": 15
}
]
}
```
Quiz Options:
- `subject`: Title/topic of the quiz
- `questions`: Array of question objects containing:
- `question`: The question text
- `answers`: Array of possible answers (2-4 options)
- `image`: Optional URL for question image
- `solution`: Index of correct answer (0-based)
- `cooldown`: Time in seconds before showing the question
- `time`: Time in seconds allowed to answer
## 🎮 How to Play
1. Access the manager interface at http://localhost:3000/manager
2. Enter the manager password (defined in quiz config)
3. Share the game URL (http://localhost:3000) and room code with participants
4. Wait for players to join
5. Click the start button to begin the game
## 📝 Contributing ## 📝 Contributing
- Create a fork 1. Fork the repository
2. Create a new branch (e.g., `feat/my-feature`)
3. Make your changes
4. Create a pull request
5. Wait for review and merge
- Create work branch (Example: githubUsername/featureName). For bug reports or feature requests, please [create an issue](https://github.com/Ralex91/Rahoot/issues).
- Commit and push your changes in the work branch.
- Open a pull request.
- Your pull request would be merged and changes will be reflected in the main repository.
+13
View File
@@ -0,0 +1,13 @@
version: "3.8"
services:
rahoot:
image: ralex91/rahoot:latest
ports:
- "3000:3000"
- "3001:3001"
volumes:
- ./config:/app/config # Path to your game config
environment:
- WEB_ORIGIN=http://localhost:3000
- SOCKET_URL=http://localhost:3001
-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,
}
+3
View File
@@ -0,0 +1,3 @@
{
"managerPassword": "PASSWORD"
}
+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
+9 -29
View File
@@ -1,36 +1,16 @@
{ {
"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 .", "lint": "pnpm -r run lint"
"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.9.3"
"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
+208
View File
@@ -0,0 +1,208 @@
import js from "@eslint/js"
import { defineConfig } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
{
ignores: ["**/node_modules/**"],
},
{
files: ["**/*.ts"],
languageOptions: {
...js.configs.recommended.languageOptions,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].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"],
},
},
])
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@rahoot/common",
"version": "1.0.0",
"type": "module",
"scripts": {
"lint": "eslint"
},
"dependencies": {
"socket.io": "^4.8.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@types/node": "^20.19.23",
"eslint": "^9.38.0",
"globals": "^16.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2"
}
}
+32
View File
@@ -0,0 +1,32 @@
export type Player = {
id: string
clientId: string
connected: boolean
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
}
+87
View File
@@ -0,0 +1,87 @@
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
import { GameUpdateQuestion, 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 {
connect: () => void
// 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:reset": (_message: string) => void
"game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (_count: number) => void
// Player events
"player:successReconnect": (_data: {
gameId: string
status: { name: Status; data: StatusDataMap[Status] }
player: { username: string; points: number }
currentQuestion: GameUpdateQuestion
}) => void
"player:updateLeaderboard": (_data: { leaderboard: Player[] }) => void
// Manager events
"manager:successReconnect": (_data: {
gameId: string
status: { name: Status; data: StatusDataMap[Status] }
players: Player[]
currentQuestion: GameUpdateQuestion
}) => void
"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:reconnect": (_message: { gameId: string }) => void
"manager:kickPlayer": (_message: { gameId: string; 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:reconnect": (_message: { gameId: string }) => void
"player:selectedAnswer": (
_message: MessageWithoutStatus<{ answerKey: number }>
) => void
// Common
disconnect: () => void
}
+55
View File
@@ -0,0 +1,55 @@
import { Player } from "."
export const 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",
} as const
export type Status = (typeof STATUS)[keyof typeof STATUS]
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: { oldLeaderboard: Player[]; leaderboard: Player[] }
}
export type PlayerStatusDataMap = CommonStatusDataMap
export type ManagerStatusDataMap = CommonStatusDataMap & ManagerExtraStatus
export type StatusDataMap = PlayerStatusDataMap & ManagerStatusDataMap
+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
}
}
+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)
+208
View File
@@ -0,0 +1,208 @@
import js from "@eslint/js"
import { defineConfig } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
{
ignores: ["**/node_modules/**", "**/dist/**"],
},
{
files: ["**/*.ts"],
languageOptions: {
...js.configs.recommended.languageOptions,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].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"],
},
},
])
+31
View File
@@ -0,0 +1,31 @@
{
"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",
"lint": "eslint"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"dayjs": "^1.11.18",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@types/node": "^24.9.1",
"esbuild": "^0.25.11",
"eslint": "^9.38.0",
"globals": "^16.4.0",
"tsx": "^4.20.6",
"typescript-eslint": "^8.46.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod/v4"
const env = createEnv({
server: {
WEB_ORIGIN: z.string().optional().default("http://localhost:3000"),
SOCKER_PORT: z.string().optional().default("3001"),
},
runtimeEnv: {
WEB_ORIGIN: process.env.WEB_ORIGIN,
SOCKER_PORT: process.env.SOCKER_PORT,
},
})
export default env
+188
View File
@@ -0,0 +1,188 @@
import { Server } from "@rahoot/common/types/game/socket"
import { inviteCodeValidator } from "@rahoot/common/validators/auth"
import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game"
import Registry from "@rahoot/socket/services/registry"
import { withGame } from "@rahoot/socket/utils/game"
import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO({
cors: {
origin: [env.WEB_ORIGIN],
},
})
Config.init()
const registry = Registry.getInstance()
const port = 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
io.on("connection", (socket) => {
console.log(
`A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
)
socket.on("player:reconnect", ({ gameId }) => {
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
if (game) {
game.reconnect(socket)
return
}
socket.emit("game:reset", "Game not found")
})
socket.on("manager:reconnect", ({ gameId }) => {
const game = registry.getManagerGame(gameId, socket.handshake.auth.clientId)
if (game) {
game.reconnect(socket)
return
}
socket.emit("game:reset", "Game expired")
})
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)
registry.addGame(game)
})
socket.on("player:join", (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message)
return
}
const game = registry.getGameByInviteCode(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, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, playerId }) =>
withGame(gameId, socket, (game) => game.kickPlayer(socket, playerId))
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, (game) => game.abortRound(socket))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, (game) => game.nextRound(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, (game) => game.showLeaderboard())
)
socket.on("disconnect", () => {
console.log(`A user disconnected : ${socket.id}`)
const managerGame = registry.getGameByManagerSocketId(socket.id)
if (managerGame) {
managerGame.manager.connected = false
registry.markGameAsEmpty(managerGame)
if (!managerGame.started) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset", "Manager disconnected")
registry.removeGame(managerGame.gameId)
return
}
}
const game = registry.getGameByPlayerSocketId(socket.id)
if (!game) {
return
}
const player = game.players.find((p) => p.id === socket.id)
if (!player) {
return
}
if (!game.started) {
game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.manager.id).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}`)
return
}
player.connected = false
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
})
})
process.on("SIGINT", () => {
Registry.getInstance().cleanup()
process.exit(0)
})
process.on("SIGTERM", () => {
Registry.getInstance().cleanup()
process.exit(0)
})
+130
View File
@@ -0,0 +1,130 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const inContainerPath = process.env.CONFIG_PATH
const getPath = (path: string = "") =>
inContainerPath
? resolve(inContainerPath, path)
: resolve(process.cwd(), "../../config", path)
class Config {
static init() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
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)
}
return {}
}
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
+530
View File
@@ -0,0 +1,530 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game"
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
import Registry from "@rahoot/socket/services/registry"
import { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
import sleep from "@rahoot/socket/utils/sleep"
import { v4 as uuid } from "uuid"
const registry = Registry.getInstance()
class Game {
io: Server
gameId: string
manager: {
id: string
clientId: string
connected: boolean
}
inviteCode: string
started: boolean
lastBroadcastStatus: { name: Status; data: StatusDataMap[Status] } | null =
null
managerStatus: { name: Status; data: StatusDataMap[Status] } | null = null
playerStatus: Map<string, { name: Status; data: StatusDataMap[Status] }> =
new Map()
leaderboard: Player[]
tempOldLeaderboard: Player[] | null
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.manager = {
id: "",
clientId: "",
connected: false,
}
this.inviteCode = ""
this.started = false
this.lastBroadcastStatus = null
this.managerStatus = null
this.playerStatus = new Map()
this.leaderboard = []
this.tempOldLeaderboard = null
this.players = []
this.round = {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
this.cooldown = {
active: false,
ms: 0,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.manager = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
connected: true,
}
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}`
)
}
broadcastStatus<T extends Status>(status: T, data: StatusDataMap[T]) {
const statusData = { name: status, data }
this.lastBroadcastStatus = statusData
this.io.to(this.gameId).emit("game:status", statusData)
}
sendStatus<T extends Status>(
target: string,
status: T,
data: StatusDataMap[T]
) {
const statusData = { name: status, data }
if (this.manager.id === target) {
this.managerStatus = statusData
} else {
this.playerStatus.set(target, statusData)
}
this.io.to(target).emit("game:status", statusData)
}
join(socket: Socket, username: string) {
const isAlreadyConnected = this.players.find(
(p) => p.clientId === socket.handshake.auth.clientId
)
if (isAlreadyConnected) {
socket.emit("game:errorMessage", "Player already connected")
return
}
socket.join(this.gameId)
const playerData = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
connected: true,
username,
points: 0,
}
this.players.push(playerData)
this.io.to(this.manager.id).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.manager.id !== 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.playerStatus.delete(playerId)
this.io.in(playerId).socketsLeave(this.gameId)
this.io
.to(player.id)
.emit("game:reset", "You have been kicked by the manager")
this.io.to(this.manager.id).emit("manager:playerKicked", player.id)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
}
reconnect(socket: Socket) {
const { clientId } = socket.handshake.auth
const isManager = this.manager.clientId === clientId
if (isManager) {
this.reconnectManager(socket)
} else {
this.reconnectPlayer(socket)
}
}
private reconnectManager(socket: Socket) {
if (this.manager.connected) {
socket.emit("game:reset", "Manager already connected")
return
}
socket.join(this.gameId)
this.manager.id = socket.id
this.manager.connected = true
const status = this.managerStatus ||
this.lastBroadcastStatus || {
name: STATUS.WAIT,
data: { text: "Waiting for players" },
}
socket.emit("manager:successReconnect", {
gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
},
status,
players: this.players,
})
socket.emit("game:totalPlayers", this.players.length)
registry.reactivateGame(this.gameId)
console.log(`Manager reconnected to game ${this.inviteCode}`)
}
private reconnectPlayer(socket: Socket) {
const { clientId } = socket.handshake.auth
const player = this.players.find((p) => p.clientId === clientId)
if (!player) {
return
}
if (player.connected) {
socket.emit("game:reset", "Player already connected")
return
}
socket.join(this.gameId)
const oldSocketId = player.id
player.id = socket.id
player.connected = true
const status = this.playerStatus.get(oldSocketId) ||
this.lastBroadcastStatus || {
name: STATUS.WAIT,
data: { text: "Waiting for players" },
}
if (this.playerStatus.has(oldSocketId)) {
const oldStatus = this.playerStatus.get(oldSocketId)!
this.playerStatus.delete(oldSocketId)
this.playerStatus.set(socket.id, oldStatus)
}
socket.emit("player:successReconnect", {
gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
},
status,
player: {
username: player.username,
points: player.points,
},
})
socket.emit("game:totalPlayers", this.players.length)
console.log(
`Player ${player.username} reconnected to game ${this.inviteCode}`
)
}
startCooldown(seconds: number): Promise<void> {
if (this.cooldown.active) {
return Promise.resolve()
}
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()
return
}
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}, 1000)
})
}
abortCooldown() {
this.cooldown.active &&= false
}
async start(socket: Socket) {
if (this.manager.id !== socket.id) {
return
}
if (this.started) {
return
}
this.started = true
this.broadcastStatus(STATUS.SHOW_START, {
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.playerStatus.clear()
this.io.to(this.gameId).emit("game:updateQuestion", {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
})
this.managerStatus = null
this.broadcastStatus(STATUS.SHOW_PREPARED, {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
})
await sleep(2)
if (!this.started) {
return
}
this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question,
image: question.image,
cooldown: question.cooldown,
})
await sleep(question.cooldown)
if (!this.started) {
return
}
this.round.startTime = Date.now()
this.broadcastStatus(STATUS.SELECT_ANSWER, {
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.showResults(question)
}
showResults(question: any) {
const oldLeaderboard =
this.leaderboard.length === 0
? this.players.map((p) => ({ ...p }))
: this.leaderboard.map((p) => ({ ...p }))
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
)
const sortedPlayers = 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.points) : 0
player.points += points
return { ...player, lastCorrect: isCorrect, lastPoints: points }
})
.sort((a, b) => b.points - a.points)
this.players = sortedPlayers
sortedPlayers.forEach((player, index) => {
const rank = index + 1
const aheadPlayer = sortedPlayers[index - 1]
this.sendStatus(player.id, STATUS.SHOW_RESULT, {
correct: player.lastCorrect,
message: player.lastCorrect ? "Nice!" : "Too bad",
points: player.lastPoints,
myPoints: player.points,
rank,
aheadOfMe: aheadPlayer ? aheadPlayer.username : null,
})
})
this.sendStatus(this.manager.id, STATUS.SHOW_RESPONSES, {
question: question.question,
responses: totalType,
correct: question.solution,
answers: question.answers,
image: question.image,
})
this.leaderboard = sortedPlayers
this.tempOldLeaderboard = oldLeaderboard
this.round.playersAnswers = []
}
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: timeToPoint(this.round.startTime, question.time),
})
this.sendStatus(socket.id, STATUS.WAIT, {
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.manager.id) {
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.manager.id) {
return
}
this.abortCooldown()
}
showLeaderboard() {
const isLastRound =
this.round.currentQuestion + 1 === this.quizz.questions.length
if (isLastRound) {
this.started = false
this.broadcastStatus(STATUS.FINISHED, {
subject: this.quizz.subject,
top: this.leaderboard.slice(0, 3),
})
return
}
const oldLeaderboard = this.tempOldLeaderboard
? this.tempOldLeaderboard
: this.leaderboard
this.sendStatus(this.manager.id, STATUS.SHOW_LEADERBOARD, {
oldLeaderboard: oldLeaderboard.slice(0, 5),
leaderboard: this.leaderboard.slice(0, 5),
})
this.tempOldLeaderboard = null
}
}
export default Game
+161
View File
@@ -0,0 +1,161 @@
import Game from "@rahoot/socket/services/game"
import dayjs from "dayjs"
interface EmptyGame {
since: number
game: Game
}
class Registry {
private static instance: Registry | null = null
private games: Game[] = []
private emptyGames: EmptyGame[] = []
private cleanupInterval: ReturnType<typeof setTimeout> | null = null
private readonly EMPTY_GAME_TIMEOUT_MINUTES = 5
private readonly CLEANUP_INTERVAL_MS = 60_000
private constructor() {
this.startCleanupTask()
}
static getInstance(): Registry {
Registry.instance ||= new Registry()
return Registry.instance
}
addGame(game: Game): void {
this.games.push(game)
console.log(`Game ${game.gameId} added. Total games: ${this.games.length}`)
}
getGameById(gameId: string): Game | undefined {
return this.games.find((g) => g.gameId === gameId)
}
getGameByInviteCode(inviteCode: string): Game | undefined {
return this.games.find((g) => g.inviteCode === inviteCode)
}
getPlayerGame(gameId: string, clientId: string): Game | undefined {
return this.games.find(
(g) =>
g.gameId === gameId && g.players.some((p) => p.clientId === clientId)
)
}
getManagerGame(gmageId: string, clientId: string): Game | undefined {
return this.games.find(
(g) => g.gameId === gmageId && g.manager.clientId === clientId
)
}
getGameByManagerSocketId(socketId: string): Game | undefined {
return this.games.find((g) => g.manager.id === socketId)
}
getGameByPlayerSocketId(socketId: string): Game | undefined {
return this.games.find((g) => g.players.some((p) => p.id === socketId))
}
markGameAsEmpty(game: Game): void {
const alreadyEmpty = this.emptyGames.find(
(g) => g.game.gameId === game.gameId
)
if (!alreadyEmpty) {
this.emptyGames.push({
since: dayjs().unix(),
game,
})
console.log(
`Game ${game.gameId} marked as empty. Total empty games: ${this.emptyGames.length}`
)
}
}
reactivateGame(gameId: string): void {
const initialLength = this.emptyGames.length
this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId)
if (this.emptyGames.length < initialLength) {
console.log(
`Game ${gameId} reactivated. Remaining empty games: ${this.emptyGames.length}`
)
}
}
removeGame(gameId: string): boolean {
const initialLength = this.games.length
this.games = this.games.filter((g) => g.gameId !== gameId)
this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId)
const removed = this.games.length < initialLength
if (removed) {
console.log(`Game ${gameId} removed. Total games: ${this.games.length}`)
}
return removed
}
getAllGames(): Game[] {
return [...this.games]
}
getGameCount(): number {
return this.games.length
}
getEmptyGameCount(): number {
return this.emptyGames.length
}
private cleanupEmptyGames(): void {
const now = dayjs()
const stillEmpty = this.emptyGames.filter(
(g) =>
now.diff(dayjs.unix(g.since), "minute") <
this.EMPTY_GAME_TIMEOUT_MINUTES
)
if (stillEmpty.length === this.emptyGames.length) {
return
}
const removed = this.emptyGames.filter((g) => !stillEmpty.includes(g))
const removedGameIds = removed.map((r) => r.game.gameId)
this.games = this.games.filter((g) => !removedGameIds.includes(g.gameId))
this.emptyGames = stillEmpty
console.log(
`Removed ${removed.length} empty game(s). Remaining games: ${this.games.length}`
)
}
private startCleanupTask(): void {
this.cleanupInterval = setInterval(() => {
this.cleanupEmptyGames()
}, this.CLEANUP_INTERVAL_MS)
console.log("Game cleanup task started")
}
stopCleanupTask(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
console.log("Game cleanup task stopped")
}
}
cleanup(): void {
this.stopCleanupTask()
this.games = []
this.emptyGames = []
console.log("Registry cleaned up")
}
}
export default Registry
+51
View File
@@ -0,0 +1,51 @@
import { Socket } from "@rahoot/common/types/game/socket"
import Game from "@rahoot/socket/services/game"
import Registry from "@rahoot/socket/services/registry"
export const withGame = (
gameId: string | undefined,
socket: Socket,
callback: (_game: Game) => void
): void => {
if (!gameId) {
socket.emit("game:errorMessage", "Game not found")
return
}
const registry = Registry.getInstance()
const game = registry.getGameById(gameId)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
callback(game)
}
export const createInviteCode = (length = 6) => {
let result = ""
const characters = "0123456789"
const charactersLength = characters.length
for (let i = 0; i < length; i += 1) {
const randomIndex = Math.floor(Math.random() * charactersLength)
result += characters.charAt(randomIndex)
}
return result
}
export const timeToPoint = (startTime: number, secondes: number): number => {
let points = 1000
const actualTime = Date.now()
const tempsPasseEnSecondes = (actualTime - startTime) / 1000
points -= (1000 / secondes) * tempsPasseEnSecondes
points = Math.max(0, points)
return points
}
+4
View File
@@ -0,0 +1,4 @@
export const sleep = (sec: number) =>
new Promise((r) => void setTimeout(r, sec * 1000))
export default sleep
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"types": ["node"],
"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.
@@ -1,20 +1,23 @@
// eslint.config.js import js from "@eslint/js"
import nextPlugin from "@next/eslint-plugin-next" import nextPlugin from "@next/eslint-plugin-next"
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"
import globals from "globals" import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([ export default defineConfig([
{ {
ignores: ["**/node_modules/**", "**/.next/**"], ignores: ["**/node_modules/**", "**/.next/**"],
}, },
{ {
files: ["**/*.js", "**/*.jsx"], files: ["**/*.{ts,tsx}"],
languageOptions: { languageOptions: {
ecmaVersion: "latest", ...js.configs.recommended.languageOptions,
sourceType: "module", parser: tseslint.parser,
parserOptions: { parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
globals: { globals: {
@@ -23,6 +26,7 @@ export default defineConfig([
}, },
}, },
plugins: { plugins: {
"@typescript-eslint": tseslint.plugin,
react: reactPlugin, react: reactPlugin,
"react-hooks": reactHooksPlugin, "react-hooks": reactHooksPlugin,
"@next/next": nextPlugin, "@next/next": nextPlugin,
@@ -33,6 +37,10 @@ export default defineConfig([
}, },
}, },
rules: { rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].rules,
...reactPlugin.configs.recommended.rules,
"array-callback-return": [ "array-callback-return": [
"error", "error",
{ allowImplicit: false, checkForEach: true, allowVoid: true }, { allowImplicit: false, checkForEach: true, allowVoid: true },
@@ -210,7 +218,7 @@ export default defineConfig([
"space-before-blocks": "error", "space-before-blocks": "error",
semi: ["error", "never"], semi: ["error", "never"],
// Extra rules // React + Hooks + Next.js
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "off", "react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": ["error", { forbid: [">", "}"] }], "react/no-unescaped-entities": ["error", { forbid: [">", "}"] }],
+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
+46
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"
}
}
+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.
+47
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="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="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
@@ -0,0 +1,45 @@
"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 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[]>([])
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} />
}
return <SelectQuizz quizzList={quizzList} onSelect={handleCreate} />
}
export default Manager
+31
View File
@@ -0,0 +1,31 @@
"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 toast from "react-hot-toast"
const Home = () => {
const { isConnected, connect } = useSocket()
const { player } = usePlayerStore()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:errorMessage", (message) => {
toast.error(message)
})
if (player) {
return <Username />
}
return <Room />
}
export default Home
@@ -0,0 +1,95 @@
"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 toast from "react-hot-toast"
const Game = () => {
const router = useRouter()
const { socket } = useSocket()
const { gameId: gameIdParam }: { gameId?: string } = useParams()
const { status, 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)
},
)
useEvent("game:status", ({ name, data }) => {
if (name in GAME_STATE_COMPONENTS) {
setStatus(name, data)
}
})
useEvent("game:reset", (message) => {
router.replace("/")
reset()
setQuestionStates(null)
toast.error(message)
})
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
+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,136 @@
"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"
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()
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)
})
const handleSkip = () => {
if (!gameId) {
return
}
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 statusName={status?.name} onNext={handleSkip} manager>
{component}
</GameWrapper>
)
}
export default ManagerGame
@@ -1,6 +1,14 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-primary: #ff9900;
--color-secondary: #1a140b;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
.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",
}
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
+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

@@ -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
+18
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
+9
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
+17
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
+12
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
@@ -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,96 @@
"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
manager?: boolean
}
const GameWrapper = ({ children, statusName, onNext, 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>
)}
</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
@@ -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
@@ -0,0 +1,64 @@
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
}
const 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 (
<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",
)}
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
@@ -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
@@ -0,0 +1,52 @@
"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)
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
@@ -0,0 +1,120 @@
"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"]
}
const 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, { stop: stopMusic }] = 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])
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="m-4 h-full max-h-[400px] min-h-[200px] 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>
)
}
export default Answers
@@ -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
@@ -1,16 +1,23 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import useScreenSize from "@rahoot/web/hooks/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"]
}
const Podium = ({ data: { subject, top } }: Props) => {
const [apparition, setApparition] = useState(0) const [apparition, setApparition] = useState(0)
const { width, height } = useScreenSize() const { width, height } = useScreenSize()
@@ -32,8 +39,6 @@ export default function Podium({ data: { subject, top } }) {
}) })
useEffect(() => { useEffect(() => {
console.log(apparition)
switch (apparition) { switch (apparition) {
case 4: case 4:
sfxRoolStop() sfxRoolStop()
@@ -56,7 +61,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 +82,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 +95,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,18 +105,19 @@ 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
className={clsx( className={clsx(
"z-20 flex h-[50%] w-full translate-y-full flex-col items-center justify-center gap-3 opacity-0 transition-all", "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 }, { "translate-y-0! opacity-100": apparition >= 2 },
)} )}
> >
<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 +125,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>
@@ -134,7 +140,7 @@ export default function Podium({ data: { subject, top } }) {
className={clsx( className={clsx(
"z-30 flex h-[60%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all", "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, "translate-y-0! opacity-100": apparition >= 3,
}, },
{ {
"md:min-w-64": top.length < 2, "md:min-w-64": top.length < 2,
@@ -143,13 +149,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>
@@ -164,13 +170,13 @@ export default function Podium({ data: { subject, top } }) {
className={clsx( className={clsx(
"z-10 flex h-[40%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all", "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, "translate-y-0! opacity-100": apparition >= 1,
}, },
)} )}
> >
<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 +184,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>
@@ -194,3 +200,5 @@ export default function Podium({ data: { subject, top } }) {
</> </>
) )
} }
export default Podium
@@ -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
@@ -1,8 +1,15 @@
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"]
}
const 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(() => {
@@ -20,14 +27,16 @@ export default function Question({ data: { question, image, cooldown } }) {
<img <img
alt={question} alt={question}
src={image} src={image}
className="h-48 max-h-60 w-auto rounded-md" className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
/> />
)} )}
</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>
) )
} }
export default Question
@@ -0,0 +1,104 @@
"use client"
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
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 },
}: 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>
)
}
export default Responses
@@ -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"
export default function Result({ type Props = {
data: CommonStatusDataMap["SHOW_RESULT"]
}
const 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}
@@ -44,3 +48,5 @@ export default function Result({
</section> </section>
) )
} }
export default Result
@@ -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
@@ -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"]
}
const 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,23 +19,16 @@ 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">
{showTitle ? ( {showTitle ? (
@@ -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)`,
@@ -54,3 +53,5 @@ export default function Start({ data: { time, subject } }) {
</section> </section>
) )
} }
export default Start
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,167 @@
/* 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 () => {
const res = await ky.get("/socket").json<{ url: string }>()
return res.url
}
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()
s = io(socketUrl, {
transports: ["websocket"],
autoConnect: false,
auth: {
clientId,
},
})
setSocket(s)
s.on("connect", () => {
setIsConnected(true)
})
s.on("disconnect", () => {
setIsConnected(false)
})
s.on("connect_error", (err) => {
console.error("Connection error:", err.message)
})
} 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
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.
+36
View File
@@ -0,0 +1,36 @@
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[]) => 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({ players }),
reset: () => set(initialState),
}))
+59
View File
@@ -0,0 +1,59 @@
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) => set({ player }),
login: (username) =>
set((state) => ({
player: { ...state.player, username },
})),
join: (gameId) => {
set((state) => ({
gameId,
player: { ...state.player, points: 0 },
}))
},
updatePoints: (points) =>
set((state) => ({
player: { ...state.player, points },
})),
setStatus: (name, data) => set({ status: createStatus(name, data) }),
reset: () => set(initialState),
}))
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more