Initial clean state
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.git
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="100%" height="100%" viewBox="5.989999771118164 -63.970001220703125 88.26000213623047 65.80000305175781"><g fill="#ff9900"><path d="M65.97 -41.84C65.97 -45.17 65.2 -48.32 63.68 -51.28C62.15 -54.25 59.98 -56.62 57.15 -58.4C55.98 -59.12 54.67 -59.7 53.2 -60.14C51.73 -60.59 50.2 -60.93 48.62 -61.18C47.04 -61.43 45.48 -61.59 43.92 -61.64C42.37 -61.7 40.93 -61.72 39.6 -61.72C34.38 -61.72 29.18 -61.56 24 -61.23C18.81 -60.89 13.61 -60.59 8.4 -60.31C8.07 -51.33 7.82 -42.34 7.65 -33.36C7.6 -28.31 7.4 -23.25 7.07 -18.18C6.74 -13.1 6.38 -8.01 5.99 -2.91C7.54 -2.75 9.08 -2.63 10.61 -2.58C12.13 -2.52 13.7 -2.5 15.31 -2.5C18.47 -2.5 21.63 -2.58 24.79 -2.75L24.37 -21.63C26.15 -21.74 27.94 -21.88 29.74 -22.04C31.54 -22.21 33.36 -22.35 35.19 -22.46C36.8 -18.3 38.04 -14.34 38.93 -10.56C39.82 -6.79 40.48 -2.66 40.93 1.83L65.22 1.25C61.11 -8.35 56.32 -17.72 50.83 -26.87C55.37 -27.09 59.04 -28.51 61.81 -31.11C64.58 -33.72 65.97 -37.3 65.97 -41.84ZM46.67 -43.17C46.67 -42.9 46.65 -42.43 46.63 -41.76C46.6 -41.09 46.57 -40.42 46.54 -39.72C46.52 -39.03 46.45 -38.36 46.34 -37.73C46.22 -37.09 46.09 -36.66 45.92 -36.44C45.53 -35.94 44.85 -35.53 43.88 -35.23C42.91 -34.92 41.86 -34.69 40.72 -34.52C39.58 -34.36 38.47 -34.25 37.39 -34.19C36.31 -34.13 35.49 -34.11 34.94 -34.11C33.66 -34.11 32.36 -34.16 31.03 -34.27C29.7 -34.38 28.42 -34.58 27.2 -34.86L24.96 -50.99C27.06 -51.33 29.18 -51.6 31.32 -51.83C33.46 -52.05 35.63 -52.16 37.85 -52.16C38.13 -52.16 38.61 -52.14 39.31 -52.12C40 -52.09 40.72 -52.03 41.47 -51.95C42.22 -51.87 42.92 -51.77 43.59 -51.66C44.26 -51.55 44.73 -51.38 45 -51.16C45.39 -50.83 45.7 -50.31 45.92 -49.62C46.14 -48.93 46.31 -48.18 46.42 -47.38C46.53 -46.57 46.6 -45.78 46.63 -45C46.65 -44.23 46.67 -43.62 46.67 -43.17Z M94.25 -63.97L74.87 -62.39C75.09 -58.56 75.28 -54.75 75.45 -50.95C75.62 -47.15 75.89 -43.37 76.28 -39.6L78.36 -18.63L87.51 -19.47C88.34 -23.63 89.19 -27.73 90.05 -31.78C90.91 -35.83 91.76 -39.93 92.59 -44.09C92.86 -45.53 93.1 -47.1 93.29 -48.79C93.49 -50.48 93.64 -52.21 93.75 -53.99C93.86 -55.76 93.96 -57.51 94.04 -59.23C94.13 -60.95 94.2 -62.53 94.25 -63.97ZM92.75 -6.65C92.75 -6.88 92.74 -7.08 92.71 -7.28C92.69 -7.47 92.62 -7.68 92.5 -7.9C91.89 -9.34 90.97 -10.43 89.72 -11.15C88.47 -11.87 87.07 -12.23 85.52 -12.23C84.57 -12.23 83.56 -12.08 82.48 -11.77C81.4 -11.47 80.4 -11.02 79.49 -10.44C78.57 -9.86 77.79 -9.14 77.16 -8.28C76.52 -7.42 76.2 -6.46 76.2 -5.41C76.2 -4.41 76.44 -3.52 76.91 -2.75C77.38 -1.97 77.99 -1.32 78.74 -0.79C79.49 -0.26 80.32 0.15 81.23 0.46C82.15 0.76 83.02 0.92 83.85 0.92C84.74 0.92 85.71 0.71 86.76 0.29C87.82 -0.12 88.79 -0.69 89.68 -1.41C90.56 -2.14 91.3 -2.95 91.88 -3.87C92.46 -4.78 92.75 -5.71 92.75 -6.65Z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 446 KiB |
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
.env
|
||||
.secrets
|
||||
release.json
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"usernamehw.errorlens",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"meganrogge.template-string-converter",
|
||||
"vincaslt.highlight-matching-tag",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"naumovs.color-highlight"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss",
|
||||
"*.scss": "tailwindcss"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Ralex
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,190 @@
|
||||
<p align="center">
|
||||
<img width="450" height="120" align="center" src="https://raw.githubusercontent.com/Ralex91/Rahoot/main/.github/logo.svg">
|
||||
<br>
|
||||
<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&color=37d67a" alt="Docker Pulls">
|
||||
</div>
|
||||
</p>
|
||||
|
||||
## 🧩 What is this project?
|
||||
|
||||
Rahoot is a straightforward and open-source clone of the Kahoot! platform, allowing users to host it on their own server for smaller events.
|
||||
|
||||
> ⚠️ 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
|
||||
|
||||
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
|
||||
|
||||
Choose your deployment method:
|
||||
|
||||
### 🐳 Using Docker (Recommended)
|
||||
|
||||
Using Docker Compose (recommended):
|
||||
You can find the docker compose configuration in the repository:
|
||||
[docker-compose.yml](/compose.yml)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or using Docker directly:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3001 \
|
||||
-v ./config:/app/config \
|
||||
-e REDIS_URL=redis://user:pass@redis:6379 \
|
||||
-e MEDIA_MAX_UPLOAD_MB=200 \
|
||||
-e WEB_ORIGIN=http://localhost:3000 \
|
||||
-e SOCKET_URL=http://localhost:3001 \
|
||||
ralex91/rahoot:latest
|
||||
```
|
||||
|
||||
**Configuration & Media Volume:**
|
||||
- `-v ./config:/app/config` mounts a local `config` folder to persist settings, quizzes, and uploaded media (`config/quizz`, `config/media`). This keeps your data across redeploys and lets you back it up easily.
|
||||
- The folder is auto-created on first run with an example quiz.
|
||||
|
||||
The application will be available at:
|
||||
|
||||
- Web Interface: http://localhost:3000
|
||||
- WebSocket Server: ws://localhost:3001
|
||||
|
||||
### 🛠️ Without Docker
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `managerPassword`: The master password for accessing the manager interface
|
||||
- `music`: Enable/disable game music
|
||||
|
||||
### 2. Quiz Configuration (`config/quizz/*.json`)
|
||||
|
||||
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/....",
|
||||
"media": { "type": "audio", "url": "https://example.com/song.mp3" },
|
||||
"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 (legacy; use `media` for new content)
|
||||
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "<link>" }`. Examples:
|
||||
- `{"type":"audio","url":"https://.../clip.mp3"}`
|
||||
- `{"type":"video","url":"https://.../clip.mp4"}`
|
||||
- `solution`: Index of correct answer (0-based)
|
||||
- `cooldown`: Time in seconds before showing the question
|
||||
- `time`: Time in seconds allowed to answer
|
||||
|
||||
Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”).
|
||||
|
||||
### Manager Features
|
||||
- Upload image/audio/video directly in the quiz editor (stored under `config/media`).
|
||||
- Manual “Set timing from media” to align cooldown/answer time with clip length.
|
||||
- Media library view: see all uploads, where they’re used, and delete unused files.
|
||||
- Delete quizzes from the editor.
|
||||
- Pause/Resume/Skip question intro and answer timers; End Game button to reset everyone.
|
||||
- Player list in manager view showing connected/disconnected players.
|
||||
- Click-to-zoom images during questions.
|
||||
- Player reconnect resilience: Redis snapshotting keeps game state; clients auto-rejoin using stored `clientId`/last game; username/points are hydrated locally after refresh without a manual reload.
|
||||
|
||||
### Resilience & Persistence
|
||||
- Redis snapshotting (set `REDIS_URL`, e.g., `redis://:password@redis:6379`) keeps game state so managers/players can reconnect without losing progress.
|
||||
- Client auto-reconnects using stored `clientId` and last `gameId`; state resumes after refresh/tab close if the game still exists.
|
||||
- `MEDIA_MAX_UPLOAD_MB` env controls upload size limit (default 50MB; set higher for video).
|
||||
|
||||
## 🎮 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
|
||||
|
||||
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
|
||||
|
||||
For bug reports or feature requests, please [create an issue](https://github.com/Ralex91/Rahoot/issues).
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"managerPassword": "PASSWORD"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"subject": "Example Quizz",
|
||||
"questions": [
|
||||
{
|
||||
"question": "Which soundtrack is this?",
|
||||
"answers": [
|
||||
"Nature sounds",
|
||||
"Piano solo",
|
||||
"Electronic beat",
|
||||
"Chill guitar"
|
||||
],
|
||||
"media": {
|
||||
"type": "audio",
|
||||
"url": "https://file-examples.com/storage/fee95f49ad692e9489b0fab/2017/11/file_example_WAV_1MG.wav"
|
||||
},
|
||||
"solution": 1,
|
||||
"cooldown": 5,
|
||||
"time": 25
|
||||
},
|
||||
{
|
||||
"question": "Which landmark appears in this clip?",
|
||||
"answers": [
|
||||
"Eiffel Tower",
|
||||
"Sydney Opera House",
|
||||
"Statue of Liberty",
|
||||
"Golden Gate Bridge"
|
||||
],
|
||||
"media": {
|
||||
"type": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
},
|
||||
"solution": 3,
|
||||
"cooldown": 5,
|
||||
"time": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "rahoot",
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env -- pnpm -r --parallel dev",
|
||||
"dev:web": "dotenv -e .env -- pnpm --filter web dev",
|
||||
"dev:socket": "dotenv -e .env -- pnpm --filter socket dev",
|
||||
"build": "pnpm -r run build",
|
||||
"start": "pnpm -r --parallel run start",
|
||||
"clean": "pnpm -r exec rm -rf dist node_modules",
|
||||
"lint": "pnpm -r run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
media?: QuestionMedia
|
||||
answers: string[]
|
||||
solution: number
|
||||
cooldown: number
|
||||
time: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export type QuestionMedia =
|
||||
| { type: "image"; url: string; fileName?: string }
|
||||
| { type: "audio"; url: string; fileName?: string }
|
||||
| { type: "video"; url: string; fileName?: string }
|
||||
|
||||
export type QuizzWithId = Quizz & { id: string }
|
||||
|
||||
export type GameUpdateQuestion = {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
|
||||
import { GameUpdateQuestion, Player, Quizz, 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:cooldownPause": (_paused: boolean) => 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:players": (_players: Player[]) => void
|
||||
"manager:errorMessage": (_message: string) => void
|
||||
"manager:playerKicked": (_playerId: string) => void
|
||||
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||
"manager:quizzDeleted": (_id: 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:pauseCooldown": (_message: MessageGameId) => void
|
||||
"manager:resumeCooldown": (_message: MessageGameId) => void
|
||||
"manager:endGame": (_message: MessageGameId) => void
|
||||
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||
"manager:deleteQuizz": (_message: { id: string }) => void
|
||||
"manager:showLeaderboard": (_message: MessageGameId) => void
|
||||
"manager:getQuizz": (_quizzId: string) => void
|
||||
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
||||
|
||||
// Player actions
|
||||
"player:join": (_inviteCode: string) => void
|
||||
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
||||
"player:reconnect": (_message: { gameId: string }) => void
|
||||
"player:selectedAnswer": (
|
||||
_message: MessageWithoutStatus<{ answerKey: number }>
|
||||
) => void
|
||||
|
||||
// Common
|
||||
disconnect: () => void
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Player, QuestionMedia } 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
|
||||
media?: QuestionMedia
|
||||
cooldown: number
|
||||
}
|
||||
SELECT_ANSWER: {
|
||||
question: string
|
||||
answers: string[]
|
||||
image?: string
|
||||
media?: QuestionMedia
|
||||
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
|
||||
media?: QuestionMedia
|
||||
}
|
||||
SHOW_LEADERBOARD: { oldLeaderboard: Player[]; leaderboard: Player[] }
|
||||
}
|
||||
|
||||
export type PlayerStatusDataMap = CommonStatusDataMap
|
||||
export type ManagerStatusDataMap = CommonStatusDataMap & ManagerExtraStatus
|
||||
export type StatusDataMap = PlayerStatusDataMap & ManagerStatusDataMap
|
||||
@@ -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")
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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",
|
||||
"redis": "^4.6.13",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,298 @@
|
||||
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 { loadSnapshot } from "@rahoot/socket/services/persistence"
|
||||
import { withGame } from "@rahoot/socket/utils/game"
|
||||
import { Server as ServerIO } from "socket.io"
|
||||
|
||||
const corsOrigins =
|
||||
process.env.NODE_ENV !== "production"
|
||||
? "*"
|
||||
: env.WEB_ORIGIN === "*"
|
||||
? "*"
|
||||
: [env.WEB_ORIGIN, "http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
const io: Server = new ServerIO({
|
||||
cors: {
|
||||
origin: corsOrigins,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: false,
|
||||
},
|
||||
})
|
||||
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}`
|
||||
)
|
||||
|
||||
const ensureGame = async (gameId: string) => {
|
||||
let game = registry.getGameById(gameId)
|
||||
if (game) return game
|
||||
|
||||
try {
|
||||
const snapshot = await loadSnapshot(gameId)
|
||||
if (snapshot) {
|
||||
const restored = await Game.fromSnapshot(io, snapshot)
|
||||
registry.addGame(restored)
|
||||
return restored
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore game", error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
socket.on("player:reconnect", ({ gameId }) => {
|
||||
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
||||
|
||||
if (game) {
|
||||
game.reconnect(socket)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ensureGame(gameId).then((restored) => {
|
||||
if (restored) {
|
||||
restored.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
|
||||
}
|
||||
|
||||
ensureGame(gameId).then((restored) => {
|
||||
if (restored) {
|
||||
restored.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("manager:getQuizz", (quizzId) => {
|
||||
const quizz = Config.getQuizz(quizzId)
|
||||
|
||||
if (!quizz) {
|
||||
socket.emit("manager:errorMessage", "Quizz not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("manager:quizzLoaded", quizz)
|
||||
})
|
||||
|
||||
socket.on("manager:saveQuizz", ({ id, quizz }) => {
|
||||
if (!quizz?.subject || !Array.isArray(quizz?.questions)) {
|
||||
socket.emit("manager:errorMessage", "Invalid quizz payload")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = Config.saveQuizz(id || null, quizz)
|
||||
|
||||
if (!saved) {
|
||||
socket.emit("manager:errorMessage", "Failed to save quizz")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("manager:quizzSaved", saved)
|
||||
socket.emit("manager:quizzList", Config.quizz())
|
||||
} catch (error) {
|
||||
console.error("Failed to save quizz", error)
|
||||
socket.emit("manager:errorMessage", "Failed to save quizz")
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("manager:deleteQuizz", ({ id }) => {
|
||||
if (!id) {
|
||||
socket.emit("manager:errorMessage", "Invalid quizz id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = Config.deleteQuizz(id)
|
||||
if (!deleted) {
|
||||
socket.emit("manager:errorMessage", "Quizz not found")
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("manager:quizzDeleted", id)
|
||||
socket.emit("manager:quizzList", Config.quizz())
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quizz", error)
|
||||
socket.emit("manager:errorMessage", "Failed to delete quizz")
|
||||
}
|
||||
})
|
||||
|
||||
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:pauseCooldown", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.pauseCooldown(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:resumeCooldown", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.resumeCooldown(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:endGame", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.endGame(socket, registry))
|
||||
)
|
||||
|
||||
socket.on("manager:nextQuestion", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.nextRound(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:skipQuestionIntro", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.skipQuestionIntro(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
|
||||
}
|
||||
|
||||
player.connected = false
|
||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||
io.to(game.manager.id).emit("manager:players", game.players)
|
||||
})
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
Registry.getInstance().cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
Registry.getInstance().cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { QuizzWithId } from "@rahoot/common/types/game"
|
||||
import fs from "fs"
|
||||
import { resolve } from "path"
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 50)
|
||||
|
||||
const inContainerPath = process.env.CONFIG_PATH
|
||||
|
||||
const getPath = (path: string = "") =>
|
||||
inContainerPath
|
||||
? resolve(inContainerPath, path)
|
||||
: resolve(process.cwd(), "../../config", path)
|
||||
|
||||
export const getConfigPath = (path: string = "") => getPath(path)
|
||||
|
||||
class Config {
|
||||
static ensureBaseFolders() {
|
||||
const isConfigFolderExists = fs.existsSync(getPath())
|
||||
|
||||
if (!isConfigFolderExists) {
|
||||
fs.mkdirSync(getPath())
|
||||
}
|
||||
|
||||
const isQuizzExists = fs.existsSync(getPath("quizz"))
|
||||
|
||||
if (!isQuizzExists) {
|
||||
fs.mkdirSync(getPath("quizz"))
|
||||
}
|
||||
|
||||
const isMediaExists = fs.existsSync(getPath("media"))
|
||||
|
||||
if (!isMediaExists) {
|
||||
fs.mkdirSync(getPath("media"))
|
||||
}
|
||||
}
|
||||
|
||||
static init() {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
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.readdirSync(getPath("quizz")).length > 0
|
||||
|
||||
if (!isQuizzExists) {
|
||||
fs.mkdirSync(getPath("quizz"), { recursive: true })
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
question: "Which soundtrack is this?",
|
||||
answers: [
|
||||
"Nature sounds",
|
||||
"Piano solo",
|
||||
"Electronic beat",
|
||||
"Chill guitar",
|
||||
],
|
||||
media: {
|
||||
type: "audio",
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
|
||||
},
|
||||
solution: 1,
|
||||
cooldown: 5,
|
||||
time: 25,
|
||||
},
|
||||
{
|
||||
question: "Which landmark appears in this clip?",
|
||||
answers: [
|
||||
"Eiffel Tower",
|
||||
"Sydney Opera House",
|
||||
"Statue of Liberty",
|
||||
"Golden Gate Bridge",
|
||||
],
|
||||
media: {
|
||||
type: "video",
|
||||
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||
},
|
||||
solution: 2,
|
||||
cooldown: 5,
|
||||
time: 40,
|
||||
},
|
||||
],
|
||||
},
|
||||
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() {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
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 || []
|
||||
}
|
||||
|
||||
static getQuizz(id: string) {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
const filePath = getPath(`quizz/${id}.json`)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, "utf-8")
|
||||
|
||||
return { id, ...JSON.parse(data) } as QuizzWithId
|
||||
}
|
||||
|
||||
static saveQuizz(
|
||||
id: string | null,
|
||||
quizz: QuizzWithId | Omit<QuizzWithId, "id">
|
||||
) {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
const slug = id
|
||||
? slugify(id)
|
||||
: slugify((quizz as any).subject || "quizz")
|
||||
const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}`
|
||||
const filePath = getPath(`quizz/${finalId}.json`)
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
subject: quizz.subject,
|
||||
questions: quizz.questions,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
return this.getQuizz(finalId)
|
||||
}
|
||||
|
||||
static deleteQuizz(id: string) {
|
||||
this.ensureBaseFolders()
|
||||
const filePath = getPath(`quizz/${id}.json`)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath)
|
||||
return true
|
||||
}
|
||||
|
||||
static getMediaPath(fileName: string = "") {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
return getPath(fileName ? `media/${fileName}` : "media")
|
||||
}
|
||||
}
|
||||
|
||||
export default Config
|
||||
@@ -0,0 +1,721 @@
|
||||
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 { saveSnapshot, loadSnapshot, deleteSnapshot, GameSnapshot } from "@rahoot/socket/services/persistence"
|
||||
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
|
||||
paused: boolean
|
||||
remaining: number
|
||||
timer: NodeJS.Timeout | null
|
||||
resolve: (() => void) | null
|
||||
}
|
||||
|
||||
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: socket.handshake.auth.clientId,
|
||||
connected: true,
|
||||
}
|
||||
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,
|
||||
paused: false,
|
||||
remaining: 0,
|
||||
timer: null,
|
||||
resolve: null,
|
||||
}
|
||||
|
||||
const roomInvite = createInviteCode()
|
||||
this.inviteCode = roomInvite
|
||||
this.manager.id = socket.id
|
||||
|
||||
this.quizz = quizz
|
||||
|
||||
socket.join(this.gameId)
|
||||
socket.emit("manager:gameCreated", {
|
||||
gameId: this.gameId,
|
||||
inviteCode: roomInvite,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
|
||||
)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
static async fromSnapshot(io: Server, snapshot: GameSnapshot) {
|
||||
const game = Object.create(Game.prototype) as Game
|
||||
game.io = io
|
||||
game.gameId = snapshot.gameId
|
||||
game.manager = {
|
||||
id: "",
|
||||
clientId: snapshot.manager?.clientId || "",
|
||||
connected: false,
|
||||
}
|
||||
game.inviteCode = snapshot.inviteCode
|
||||
game.started = snapshot.started
|
||||
game.lastBroadcastStatus = snapshot.lastBroadcastStatus || null
|
||||
game.managerStatus = snapshot.managerStatus || null
|
||||
game.playerStatus = new Map()
|
||||
game.leaderboard = snapshot.leaderboard || []
|
||||
game.tempOldLeaderboard = snapshot.tempOldLeaderboard || null
|
||||
game.quizz = snapshot.quizz
|
||||
game.players = (snapshot.players || []).map((p: Player) => ({
|
||||
...p,
|
||||
id: "",
|
||||
connected: false,
|
||||
}))
|
||||
game.round = snapshot.round || {
|
||||
playersAnswers: [],
|
||||
currentQuestion: 0,
|
||||
startTime: 0,
|
||||
}
|
||||
game.cooldown = {
|
||||
active: snapshot.cooldown?.active || false,
|
||||
paused: snapshot.cooldown?.paused || false,
|
||||
remaining: snapshot.cooldown?.remaining || 0,
|
||||
timer: null,
|
||||
resolve: null,
|
||||
}
|
||||
|
||||
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
|
||||
game.startCooldown(game.cooldown.remaining)
|
||||
}
|
||||
|
||||
return game
|
||||
}
|
||||
|
||||
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)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
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)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
toSnapshot(): GameSnapshot {
|
||||
return {
|
||||
gameId: this.gameId,
|
||||
inviteCode: this.inviteCode,
|
||||
started: this.started,
|
||||
manager: {
|
||||
clientId: this.manager.clientId,
|
||||
},
|
||||
lastBroadcastStatus: this.lastBroadcastStatus,
|
||||
managerStatus: this.managerStatus,
|
||||
leaderboard: this.leaderboard,
|
||||
tempOldLeaderboard: this.tempOldLeaderboard,
|
||||
quizz: this.quizz,
|
||||
players: this.players.map((p) => ({
|
||||
...p,
|
||||
id: undefined,
|
||||
connected: false,
|
||||
})),
|
||||
round: this.round,
|
||||
cooldown: {
|
||||
active: this.cooldown.active,
|
||||
paused: this.cooldown.paused,
|
||||
remaining: this.cooldown.remaining,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async persist() {
|
||||
try {
|
||||
await saveSnapshot(this.gameId, this.toSnapshot())
|
||||
} catch (error) {
|
||||
console.error("Failed to persist game snapshot", error)
|
||||
}
|
||||
}
|
||||
|
||||
async clearPersisted() {
|
||||
try {
|
||||
await deleteSnapshot(this.gameId)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete game snapshot", error)
|
||||
}
|
||||
}
|
||||
|
||||
join(socket: Socket, username: string) {
|
||||
const existing = this.players.find(
|
||||
(p) => p.clientId === socket.handshake.auth.clientId
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
// Reconnect existing player (even before game start)
|
||||
existing.id = socket.id
|
||||
existing.connected = true
|
||||
if (username) existing.username = username
|
||||
socket.join(this.gameId)
|
||||
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||
socket.emit("game:successJoin", this.gameId)
|
||||
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.manager.id).emit("manager:players", this.players)
|
||||
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.manager.id).emit("manager:players", this.players)
|
||||
|
||||
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)
|
||||
}
|
||||
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||
|
||||
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
|
||||
this.cooldown.paused = false
|
||||
this.cooldown.remaining = seconds
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.cooldown.resolve = resolve
|
||||
|
||||
const tick = () => {
|
||||
if (!this.cooldown.active) {
|
||||
this.finishCooldown()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.remaining -= 1
|
||||
|
||||
if (this.cooldown.remaining <= 0) {
|
||||
this.finishCooldown()
|
||||
return
|
||||
}
|
||||
|
||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
// initial emit
|
||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||
this.persist()
|
||||
|
||||
this.cooldown.timer = setInterval(tick, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
abortCooldown() {
|
||||
if (!this.cooldown.active) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.active = false
|
||||
this.cooldown.paused = false
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||
this.persist()
|
||||
this.finishCooldown()
|
||||
}
|
||||
|
||||
finishCooldown() {
|
||||
if (this.cooldown.timer) {
|
||||
clearInterval(this.cooldown.timer)
|
||||
}
|
||||
this.cooldown.timer = null
|
||||
this.cooldown.active = false
|
||||
this.cooldown.paused = false
|
||||
this.cooldown.remaining = 0
|
||||
if (this.cooldown.resolve) {
|
||||
this.cooldown.resolve()
|
||||
}
|
||||
this.cooldown.resolve = null
|
||||
}
|
||||
|
||||
pauseCooldown(socket: Socket) {
|
||||
if (this.manager.id !== socket.id || !this.cooldown.active || this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.paused = true
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", true)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
resumeCooldown(socket: Socket) {
|
||||
if (this.manager.id !== socket.id || !this.cooldown.active || !this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.paused = false
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
skipQuestionIntro(socket: Socket) {
|
||||
if (this.manager.id !== socket.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.started) {
|
||||
return
|
||||
}
|
||||
|
||||
this.abortCooldown()
|
||||
}
|
||||
|
||||
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()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
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,
|
||||
media: question.media,
|
||||
cooldown: question.cooldown,
|
||||
})
|
||||
|
||||
await this.startCooldown(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,
|
||||
media: question.media,
|
||||
time: question.time,
|
||||
totalPlayer: this.players.length,
|
||||
})
|
||||
|
||||
await this.startCooldown(question.time)
|
||||
|
||||
if (!this.started) {
|
||||
return
|
||||
}
|
||||
|
||||
this.showResults(question)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
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,
|
||||
media: question.media,
|
||||
})
|
||||
|
||||
this.leaderboard = sortedPlayers
|
||||
this.tempOldLeaderboard = oldLeaderboard
|
||||
|
||||
this.round.playersAnswers = []
|
||||
this.persist()
|
||||
}
|
||||
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()
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
this.clearPersisted()
|
||||
|
||||
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
|
||||
this.persist()
|
||||
}
|
||||
|
||||
endGame(socket: Socket, registry: typeof Registry.prototype) {
|
||||
if (socket.id !== this.manager.id) {
|
||||
return
|
||||
}
|
||||
this.started = false
|
||||
this.abortCooldown()
|
||||
this.io.to(this.gameId).emit("game:reset", "Game ended by manager")
|
||||
registry.removeGame(this.gameId)
|
||||
}
|
||||
}
|
||||
|
||||
export default Game
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from "redis"
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
|
||||
|
||||
const redis =
|
||||
createClient({ url: redisUrl })
|
||||
.on("error", (err) => console.error("Redis Client Error", err))
|
||||
|
||||
export type GameSnapshot = Record<string, any>
|
||||
|
||||
export const connectRedis = async () => {
|
||||
if (!redis.isOpen) {
|
||||
await redis.connect()
|
||||
}
|
||||
}
|
||||
|
||||
export const saveSnapshot = async (gameId: string, snapshot: GameSnapshot) => {
|
||||
if (!gameId) return
|
||||
await connectRedis()
|
||||
await redis.set(`game:${gameId}`, JSON.stringify(snapshot), {
|
||||
EX: 60 * 60 * 6, // 6 hours
|
||||
})
|
||||
}
|
||||
|
||||
export const loadSnapshot = async (gameId: string): Promise<GameSnapshot | null> => {
|
||||
if (!gameId) return null
|
||||
await connectRedis()
|
||||
const raw = await redis.get(`game:${gameId}`)
|
||||
return raw ? (JSON.parse(raw) as GameSnapshot) : null
|
||||
}
|
||||
|
||||
export const deleteSnapshot = async (gameId: string) => {
|
||||
if (!gameId) return
|
||||
await connectRedis()
|
||||
await redis.del(`game:${gameId}`)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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 game = this.games.find((g) => g.gameId === gameId)
|
||||
void game?.clearPersisted?.()
|
||||
|
||||
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)
|
||||
removed.forEach((entry) => void entry.game.clearPersisted?.())
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const sleep = (sec: number) =>
|
||||
new Promise((r) => void setTimeout(r, sec * 1000))
|
||||
|
||||
export default sleep
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import js from "@eslint/js"
|
||||
import nextPlugin from "@next/eslint-plugin-next"
|
||||
import reactPlugin from "eslint-plugin-react"
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks"
|
||||
import { defineConfig } from "eslint/config"
|
||||
import globals from "globals"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ["**/node_modules/**", "**/.next/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
...js.configs.recommended.languageOptions,
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
react: reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...tseslint.configs.recommendedTypeChecked[0].rules,
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
"array-callback-return": [
|
||||
"error",
|
||||
{ allowImplicit: false, checkForEach: true, allowVoid: true },
|
||||
],
|
||||
"no-await-in-loop": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-duplicate-imports": ["error", { includeExports: true }],
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-promise-executor-return": ["error", { allowVoid: true }],
|
||||
"no-self-compare": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unreachable-loop": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
camelcase: [
|
||||
"error",
|
||||
{
|
||||
properties: "always",
|
||||
ignoreDestructuring: true,
|
||||
ignoreImports: true,
|
||||
ignoreGlobals: true,
|
||||
},
|
||||
],
|
||||
"capitalized-comments": [
|
||||
"error",
|
||||
"always",
|
||||
{ ignoreConsecutiveComments: true },
|
||||
],
|
||||
"class-methods-use-this": ["error", { enforceForClassFields: true }],
|
||||
complexity: ["warn", 40],
|
||||
"consistent-return": "error",
|
||||
curly: ["error", "all"],
|
||||
"default-param-last": "error",
|
||||
"dot-notation": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
"func-name-matching": "error",
|
||||
"func-names": "error",
|
||||
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
|
||||
"grouped-accessor-pairs": ["error", "getBeforeSet"],
|
||||
"guard-for-in": "error",
|
||||
"init-declarations": ["error", "always"],
|
||||
"logical-assignment-operators": [
|
||||
"error",
|
||||
"always",
|
||||
{ enforceForIfStatements: true },
|
||||
],
|
||||
"max-classes-per-file": ["error", { ignoreExpressions: true }],
|
||||
"max-depth": ["error", 3],
|
||||
"max-lines": [
|
||||
"error",
|
||||
{ max: 500, skipBlankLines: true, skipComments: true },
|
||||
],
|
||||
"max-nested-callbacks": ["error", 3],
|
||||
"max-params": ["error", 4],
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"no-alert": "error",
|
||||
"no-bitwise": "error",
|
||||
"no-caller": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-function": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "error",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-labels": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-object-constructor": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "error",
|
||||
"no-plusplus": "error",
|
||||
"no-proto": "error",
|
||||
"no-return-assign": ["error", "always"],
|
||||
"no-script-url": "error",
|
||||
"no-sequences": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
|
||||
"no-unused-expressions": ["error", { enforceForJSX: true }],
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": ["error", { enforceForClassMembers: true }],
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-warning-comments": ["error", { terms: ["todo"] }],
|
||||
"object-shorthand": ["error", "always"],
|
||||
"one-var": ["error", "never"],
|
||||
"operator-assignment": ["error", "always"],
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": [
|
||||
"error",
|
||||
{ destructuring: "any", ignoreReadBeforeAssign: false },
|
||||
],
|
||||
"prefer-destructuring": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-has-own": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
radix: "error",
|
||||
"require-await": "error",
|
||||
"require-unicode-regexp": "error",
|
||||
"symbol-description": "error",
|
||||
yoda: "error",
|
||||
"line-comment-position": ["error", { position: "above" }],
|
||||
indent: "off",
|
||||
"newline-before-return": "error",
|
||||
"no-undef": "error",
|
||||
"padded-blocks": ["error", "never"],
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: "*",
|
||||
next: [
|
||||
"break",
|
||||
"case",
|
||||
"cjs-export",
|
||||
"class",
|
||||
"continue",
|
||||
"do",
|
||||
"if",
|
||||
"switch",
|
||||
"try",
|
||||
"while",
|
||||
"return",
|
||||
],
|
||||
},
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: [
|
||||
"break",
|
||||
"case",
|
||||
"cjs-export",
|
||||
"class",
|
||||
"continue",
|
||||
"do",
|
||||
"if",
|
||||
"switch",
|
||||
"try",
|
||||
"while",
|
||||
"return",
|
||||
],
|
||||
next: "*",
|
||||
},
|
||||
],
|
||||
quotes: [
|
||||
"error",
|
||||
"double",
|
||||
{ avoidEscape: true, allowTemplateLiterals: true },
|
||||
],
|
||||
"space-before-blocks": "error",
|
||||
semi: ["error", "never"],
|
||||
|
||||
// React + Hooks + Next.js
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/no-unescaped-entities": ["error", { forbid: [">", "}"] }],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/jsx-uses-react": "off",
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,10 @@
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
productionBrowserSourceMaps: false,
|
||||
transpilePackages: ["packages/*", "@t3-oss/env-nextjs"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="100%" height="100%" viewBox="5.989999771118164 -63.970001220703125 88.26000213623047 65.80000305175781"><g fill="#ff9900"><path d="M65.97 -41.84C65.97 -45.17 65.2 -48.32 63.68 -51.28C62.15 -54.25 59.98 -56.62 57.15 -58.4C55.98 -59.12 54.67 -59.7 53.2 -60.14C51.73 -60.59 50.2 -60.93 48.62 -61.18C47.04 -61.43 45.48 -61.59 43.92 -61.64C42.37 -61.7 40.93 -61.72 39.6 -61.72C34.38 -61.72 29.18 -61.56 24 -61.23C18.81 -60.89 13.61 -60.59 8.4 -60.31C8.07 -51.33 7.82 -42.34 7.65 -33.36C7.6 -28.31 7.4 -23.25 7.07 -18.18C6.74 -13.1 6.38 -8.01 5.99 -2.91C7.54 -2.75 9.08 -2.63 10.61 -2.58C12.13 -2.52 13.7 -2.5 15.31 -2.5C18.47 -2.5 21.63 -2.58 24.79 -2.75L24.37 -21.63C26.15 -21.74 27.94 -21.88 29.74 -22.04C31.54 -22.21 33.36 -22.35 35.19 -22.46C36.8 -18.3 38.04 -14.34 38.93 -10.56C39.82 -6.79 40.48 -2.66 40.93 1.83L65.22 1.25C61.11 -8.35 56.32 -17.72 50.83 -26.87C55.37 -27.09 59.04 -28.51 61.81 -31.11C64.58 -33.72 65.97 -37.3 65.97 -41.84ZM46.67 -43.17C46.67 -42.9 46.65 -42.43 46.63 -41.76C46.6 -41.09 46.57 -40.42 46.54 -39.72C46.52 -39.03 46.45 -38.36 46.34 -37.73C46.22 -37.09 46.09 -36.66 45.92 -36.44C45.53 -35.94 44.85 -35.53 43.88 -35.23C42.91 -34.92 41.86 -34.69 40.72 -34.52C39.58 -34.36 38.47 -34.25 37.39 -34.19C36.31 -34.13 35.49 -34.11 34.94 -34.11C33.66 -34.11 32.36 -34.16 31.03 -34.27C29.7 -34.38 28.42 -34.58 27.2 -34.86L24.96 -50.99C27.06 -51.33 29.18 -51.6 31.32 -51.83C33.46 -52.05 35.63 -52.16 37.85 -52.16C38.13 -52.16 38.61 -52.14 39.31 -52.12C40 -52.09 40.72 -52.03 41.47 -51.95C42.22 -51.87 42.92 -51.77 43.59 -51.66C44.26 -51.55 44.73 -51.38 45 -51.16C45.39 -50.83 45.7 -50.31 45.92 -49.62C46.14 -48.93 46.31 -48.18 46.42 -47.38C46.53 -46.57 46.6 -45.78 46.63 -45C46.65 -44.23 46.67 -43.62 46.67 -43.17Z M94.25 -63.97L74.87 -62.39C75.09 -58.56 75.28 -54.75 75.45 -50.95C75.62 -47.15 75.89 -43.37 76.28 -39.6L78.36 -18.63L87.51 -19.47C88.34 -23.63 89.19 -27.73 90.05 -31.78C90.91 -35.83 91.76 -39.93 92.59 -44.09C92.86 -45.53 93.1 -47.1 93.29 -48.79C93.49 -50.48 93.64 -52.21 93.75 -53.99C93.86 -55.76 93.96 -57.51 94.04 -59.23C94.13 -60.95 94.2 -62.53 94.25 -63.97ZM92.75 -6.65C92.75 -6.88 92.74 -7.08 92.71 -7.28C92.69 -7.47 92.62 -7.68 92.5 -7.9C91.89 -9.34 90.97 -10.43 89.72 -11.15C88.47 -11.87 87.07 -12.23 85.52 -12.23C84.57 -12.23 83.56 -12.08 82.48 -11.77C81.4 -11.47 80.4 -11.02 79.49 -10.44C78.57 -9.86 77.79 -9.14 77.16 -8.28C76.52 -7.42 76.2 -6.46 76.2 -5.41C76.2 -4.41 76.44 -3.52 76.91 -2.75C77.38 -1.97 77.99 -1.32 78.74 -0.79C79.49 -0.26 80.32 0.15 81.23 0.46C82.15 0.76 83.02 0.92 83.85 0.92C84.74 0.92 85.71 0.71 86.76 0.29C87.82 -0.12 88.79 -0.69 89.68 -1.41C90.56 -2.14 91.3 -2.95 91.88 -3.87C92.46 -4.78 92.75 -5.71 92.75 -6.65Z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import logo from "@rahoot/web/assets/logo.svg"
|
||||
import Loader from "@rahoot/web/components/Loader"
|
||||
import { useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import Image from "next/image"
|
||||
import { PropsWithChildren, useEffect } from "react"
|
||||
|
||||
const AuthLayout = ({ children }: PropsWithChildren) => {
|
||||
const { isConnected, connect } = useSocket()
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
connect()
|
||||
}
|
||||
}, [connect, isConnected])
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
|
||||
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
|
||||
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
||||
</div>
|
||||
|
||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
||||
<Loader className="h-23" />
|
||||
<h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
|
||||
Loading...
|
||||
</h2>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
|
||||
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
|
||||
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
||||
</div>
|
||||
|
||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthLayout
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { QuizzWithId } from "@rahoot/common/types/game"
|
||||
import { STATUS } from "@rahoot/common/types/game/status"
|
||||
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
||||
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
||||
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
|
||||
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { useManagerStore } from "@rahoot/web/stores/manager"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
const Manager = () => {
|
||||
const { setGameId, setStatus } = useManagerStore()
|
||||
const router = useRouter()
|
||||
const { socket } = useSocket()
|
||||
|
||||
const [isAuth, setIsAuth] = useState(false)
|
||||
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
||||
const [showEditor, setShowEditor] = useState(false)
|
||||
const [showMedia, setShowMedia] = useState(false)
|
||||
|
||||
useEvent("manager:quizzList", (quizzList) => {
|
||||
setIsAuth(true)
|
||||
setQuizzList(quizzList)
|
||||
})
|
||||
|
||||
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
|
||||
setGameId(gameId)
|
||||
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
|
||||
router.push(`/game/manager/${gameId}`)
|
||||
})
|
||||
|
||||
const handleAuth = (password: string) => {
|
||||
socket?.emit("manager:auth", password)
|
||||
}
|
||||
const handleCreate = (quizzId: string) => {
|
||||
socket?.emit("game:create", quizzId)
|
||||
}
|
||||
|
||||
if (!isAuth) {
|
||||
return <ManagerPassword onSubmit={handleAuth} />
|
||||
}
|
||||
|
||||
if (showEditor) {
|
||||
return (
|
||||
<QuizEditor
|
||||
quizzList={quizzList}
|
||||
onBack={() => setShowEditor(false)}
|
||||
onListUpdate={setQuizzList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (showMedia) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowMedia(false)}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-white"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<MediaLibrary />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectQuizz
|
||||
quizzList={quizzList}
|
||||
onSelect={handleCreate}
|
||||
onManage={() => setShowEditor(true)}
|
||||
onMedia={() => setShowMedia(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Manager
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import Room from "@rahoot/web/components/game/join/Room"
|
||||
import Username from "@rahoot/web/components/game/join/Username"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const Home = () => {
|
||||
const { isConnected, connect, socket } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
connect()
|
||||
}
|
||||
}, [connect, isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return
|
||||
try {
|
||||
const storedGameId = localStorage.getItem("last_game_id")
|
||||
if (storedGameId) {
|
||||
socket?.emit("player:reconnect", { gameId: storedGameId })
|
||||
router.replace(`/game/${storedGameId}`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isConnected, socket, router])
|
||||
|
||||
useEvent("game:errorMessage", (message) => {
|
||||
toast.error(message)
|
||||
})
|
||||
|
||||
if (player) {
|
||||
return <Username />
|
||||
}
|
||||
|
||||
return <Room />
|
||||
}
|
||||
|
||||
export default Home
|
||||
@@ -0,0 +1,30 @@
|
||||
import { deleteMediaFile } from "@rahoot/web/server/media"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ file: string }> },
|
||||
) {
|
||||
try {
|
||||
const params = await context.params
|
||||
const fileParam = params.file
|
||||
|
||||
if (!fileParam) {
|
||||
return NextResponse.json({ error: "Missing file parameter" }, { status: 400 })
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(fileParam)
|
||||
await deleteMediaFile(decoded)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to delete file"
|
||||
|
||||
const status = message.includes("not found") ? 404 : 400
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { listStoredMedia, storeMediaFile } from "@rahoot/web/server/media"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const media = await listStoredMedia()
|
||||
|
||||
return NextResponse.json({ media })
|
||||
} catch (error) {
|
||||
console.error("Failed to list media", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Unable to list uploaded media" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("file")
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file received" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const media = await storeMediaFile(file)
|
||||
|
||||
return NextResponse.json({ media })
|
||||
} catch (error) {
|
||||
console.error("Failed to store media", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to upload file"
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { STATUS } from "@rahoot/common/types/game/status"
|
||||
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
|
||||
import Answers from "@rahoot/web/components/game/states/Answers"
|
||||
import Prepared from "@rahoot/web/components/game/states/Prepared"
|
||||
import Question from "@rahoot/web/components/game/states/Question"
|
||||
import Result from "@rahoot/web/components/game/states/Result"
|
||||
import Start from "@rahoot/web/components/game/states/Start"
|
||||
import Wait from "@rahoot/web/components/game/states/Wait"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const Game = () => {
|
||||
const router = useRouter()
|
||||
const { socket } = useSocket()
|
||||
const { gameId: gameIdParam }: { gameId?: string } = useParams()
|
||||
const { status, player, setPlayer, setGameId, setStatus, reset } =
|
||||
usePlayerStore()
|
||||
const { setQuestionStates } = useQuestionStore()
|
||||
|
||||
useEvent("connect", () => {
|
||||
if (gameIdParam) {
|
||||
socket?.emit("player:reconnect", { gameId: gameIdParam })
|
||||
}
|
||||
})
|
||||
|
||||
useEvent(
|
||||
"player:successReconnect",
|
||||
({ gameId, status, player, currentQuestion }) => {
|
||||
setGameId(gameId)
|
||||
setStatus(status.name, status.data)
|
||||
setPlayer(player)
|
||||
setQuestionStates(currentQuestion)
|
||||
try {
|
||||
localStorage.setItem("last_game_id", gameId)
|
||||
if (player?.username) {
|
||||
localStorage.setItem("last_username", player.username)
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
)
|
||||
|
||||
useEvent("game:status", ({ name, data }) => {
|
||||
if (name in GAME_STATE_COMPONENTS) {
|
||||
setStatus(name, data)
|
||||
}
|
||||
})
|
||||
|
||||
useEvent("game:reset", (message) => {
|
||||
router.replace("/")
|
||||
reset()
|
||||
setQuestionStates(null)
|
||||
try {
|
||||
localStorage.removeItem("last_game_id")
|
||||
localStorage.removeItem("last_username")
|
||||
localStorage.removeItem("last_points")
|
||||
} catch {}
|
||||
toast.error(message)
|
||||
})
|
||||
|
||||
// Hydrate username/points for footer immediately after refresh
|
||||
useEffect(() => {
|
||||
if (player?.username) return
|
||||
try {
|
||||
const name = localStorage.getItem("last_username")
|
||||
const ptsRaw = localStorage.getItem("last_points")
|
||||
const pts = ptsRaw ? Number(ptsRaw) : undefined
|
||||
if (name || typeof pts === "number") {
|
||||
setPlayer({
|
||||
username: name || undefined,
|
||||
points: pts,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [player?.username, setPlayer])
|
||||
|
||||
if (!gameIdParam) {
|
||||
return null
|
||||
}
|
||||
|
||||
let component = null
|
||||
|
||||
switch (status?.name) {
|
||||
case STATUS.WAIT:
|
||||
component = <Wait data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_START:
|
||||
component = <Start data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_PREPARED:
|
||||
component = <Prepared data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_QUESTION:
|
||||
component = <Question data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_RESULT:
|
||||
component = <Result data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SELECT_ANSWER:
|
||||
component = <Answers data={status.data} />
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return <GameWrapper statusName={status?.name}>{component}</GameWrapper>
|
||||
}
|
||||
|
||||
export default Game
|
||||
@@ -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,185 @@
|
||||
"use client"
|
||||
|
||||
import { STATUS } from "@rahoot/common/types/game/status"
|
||||
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
|
||||
import Answers from "@rahoot/web/components/game/states/Answers"
|
||||
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
|
||||
import Podium from "@rahoot/web/components/game/states/Podium"
|
||||
import Prepared from "@rahoot/web/components/game/states/Prepared"
|
||||
import Question from "@rahoot/web/components/game/states/Question"
|
||||
import Responses from "@rahoot/web/components/game/states/Responses"
|
||||
import Room from "@rahoot/web/components/game/states/Room"
|
||||
import Start from "@rahoot/web/components/game/states/Start"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { useManagerStore } from "@rahoot/web/stores/manager"
|
||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from "react"
|
||||
|
||||
const ManagerGame = () => {
|
||||
const router = useRouter()
|
||||
const { gameId: gameIdParam }: { gameId?: string } = useParams()
|
||||
const { socket } = useSocket()
|
||||
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
|
||||
useManagerStore()
|
||||
const { setQuestionStates } = useQuestionStore()
|
||||
const [cooldownPaused, setCooldownPaused] = useState(false)
|
||||
const { players } = useManagerStore()
|
||||
|
||||
useEvent("game:status", ({ name, data }) => {
|
||||
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
||||
setStatus(name, data)
|
||||
}
|
||||
})
|
||||
|
||||
useEvent("connect", () => {
|
||||
if (gameIdParam) {
|
||||
socket?.emit("manager:reconnect", { gameId: gameIdParam })
|
||||
}
|
||||
})
|
||||
|
||||
useEvent(
|
||||
"manager:successReconnect",
|
||||
({ gameId, status, players, currentQuestion }) => {
|
||||
setGameId(gameId)
|
||||
setStatus(status.name, status.data)
|
||||
setPlayers(players)
|
||||
setQuestionStates(currentQuestion)
|
||||
},
|
||||
)
|
||||
|
||||
useEvent("game:reset", (message) => {
|
||||
router.replace("/manager")
|
||||
reset()
|
||||
setQuestionStates(null)
|
||||
toast.error(message)
|
||||
})
|
||||
|
||||
useEvent("manager:newPlayer", (player) => {
|
||||
setPlayers((prev) => [...prev.filter((p) => p.id !== player.id), player])
|
||||
})
|
||||
|
||||
useEvent("manager:removePlayer", (playerId) => {
|
||||
setPlayers((prev) => prev.filter((p) => p.id !== playerId))
|
||||
})
|
||||
|
||||
useEvent("manager:players", (players) => {
|
||||
setPlayers(players)
|
||||
})
|
||||
|
||||
useEvent("game:cooldownPause", (isPaused) => {
|
||||
setCooldownPaused(isPaused)
|
||||
})
|
||||
|
||||
const handleSkip = () => {
|
||||
if (!gameId) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (status?.name) {
|
||||
case STATUS.SHOW_ROOM:
|
||||
socket?.emit("manager:startGame", { gameId })
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_QUESTION:
|
||||
socket?.emit("manager:skipQuestionIntro", { gameId })
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SELECT_ANSWER:
|
||||
socket?.emit("manager:abortQuiz", { gameId })
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_RESPONSES:
|
||||
socket?.emit("manager:showLeaderboard", { gameId })
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_LEADERBOARD:
|
||||
socket?.emit("manager:nextQuestion", { gameId })
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
if (!gameId) return
|
||||
if (cooldownPaused) {
|
||||
socket?.emit("manager:resumeCooldown", { gameId })
|
||||
} else {
|
||||
socket?.emit("manager:pauseCooldown", { gameId })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEndGame = () => {
|
||||
if (!gameId) return
|
||||
socket?.emit("manager:endGame", { gameId })
|
||||
}
|
||||
|
||||
let component = null
|
||||
|
||||
switch (status?.name) {
|
||||
case STATUS.SHOW_ROOM:
|
||||
component = <Room data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_START:
|
||||
component = <Start data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_PREPARED:
|
||||
component = <Prepared data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_QUESTION:
|
||||
component = <Question data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SELECT_ANSWER:
|
||||
component = <Answers data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_RESPONSES:
|
||||
component = <Responses data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_LEADERBOARD:
|
||||
component = <Leaderboard data={status.data} />
|
||||
|
||||
break
|
||||
|
||||
case STATUS.FINISHED:
|
||||
component = <Podium data={status.data} />
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<GameWrapper
|
||||
statusName={status?.name}
|
||||
onNext={handleSkip}
|
||||
onPause={handlePauseToggle}
|
||||
paused={cooldownPaused}
|
||||
showPause={
|
||||
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
||||
}
|
||||
onEnd={handleEndGame}
|
||||
players={players}
|
||||
manager
|
||||
>
|
||||
{component}
|
||||
</GameWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default ManagerGame
|
||||
@@ -0,0 +1,199 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #ff9900;
|
||||
--color-secondary: #1a140b;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-shadow {
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
|
||||
}
|
||||
|
||||
.btn-shadow span {
|
||||
display: block;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-shadow:hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px -2px inset;
|
||||
}
|
||||
|
||||
.btn-shadow:hover span {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-shadow:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.text-outline {
|
||||
-webkit-text-stroke: 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.shadow-inset {
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
height: 200%;
|
||||
width: 200%;
|
||||
z-index: 100;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
transparent 180px,
|
||||
rgba(0, 0, 0, 0.6) 200px
|
||||
);
|
||||
opacity: 0;
|
||||
left: -50%;
|
||||
top: -50%;
|
||||
transition: all 0.5s;
|
||||
animation: spotlightAnim 2.5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes spotlightAnim {
|
||||
0% {
|
||||
left: -20%;
|
||||
top: -20%;
|
||||
}
|
||||
30% {
|
||||
opacity: 100;
|
||||
top: -80%;
|
||||
left: -80%;
|
||||
}
|
||||
60% {
|
||||
top: -50%;
|
||||
left: -20%;
|
||||
}
|
||||
80% {
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
}
|
||||
98% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.anim-show {
|
||||
animation: show 0.5s ease-out;
|
||||
}
|
||||
|
||||
.anim-timer {
|
||||
animation: timer 1s ease-out infinite;
|
||||
}
|
||||
|
||||
.anim-quizz {
|
||||
animation: quizz 0.8s linear;
|
||||
transform: perspective(1200px) rotateY(-15deg) rotateX(15deg)
|
||||
translateZ(100px);
|
||||
box-shadow: 10px 10px 0 rgba(20, 24, 29, 1);
|
||||
}
|
||||
|
||||
.anim-quizz .button {
|
||||
box-shadow: rgba(0, 0, 0, 0.25) -4px -4px inset;
|
||||
animation: quizzButton 0.8s ease-out;
|
||||
}
|
||||
|
||||
.anim-balanced {
|
||||
animation: balanced 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes balanced {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-10deg) translateY(-10px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg) translateY(0px);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(10deg) translateY(-10px);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
30% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressBar {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes timer {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.4) rotate(-6deg);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.8) rotate(6deg);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes quizz {
|
||||
0% {
|
||||
transform: scale(0) perspective(1200px) rotateY(-60deg) rotateX(60deg)
|
||||
translateZ(100px);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
|
||||
translateZ(100px);
|
||||
}
|
||||
80% {
|
||||
transform: scale(0.8) perspective(1200px) rotateY(-15deg) rotateX(15deg)
|
||||
translateZ(100px);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) perspective(1200px) rotateY(-15deg) rotateX(15deg)
|
||||
translateZ(100px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes quizzButton {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1);
|
||||
}
|
||||
80% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,86 @@
|
||||
import Config from "@rahoot/socket/services/config"
|
||||
import { mimeForStoredFile } from "@rahoot/web/server/media"
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import { Readable } from "node:stream"
|
||||
import path from "path"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
context: { params: Promise<{ file: string }> },
|
||||
) {
|
||||
const params = await context.params
|
||||
const safeName = path.basename(params.file)
|
||||
|
||||
if (safeName !== params.file) {
|
||||
return NextResponse.json({ error: "Invalid file name" }, { status: 400 })
|
||||
}
|
||||
|
||||
const filePath = Config.getMediaPath(safeName)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(filePath)
|
||||
const fileSize = stat.size
|
||||
const mime = mimeForStoredFile(safeName)
|
||||
const range = _request.headers.get("range")
|
||||
|
||||
// Basic range support improves Safari/iOS playback
|
||||
if (range) {
|
||||
const bytesPrefix = "bytes="
|
||||
if (!range.startsWith(bytesPrefix)) {
|
||||
return new NextResponse(null, { status: 416 })
|
||||
}
|
||||
|
||||
const [rawStart, rawEnd] = range.replace(bytesPrefix, "").split("-")
|
||||
const start = Number(rawStart)
|
||||
const end = rawEnd ? Number(rawEnd) : fileSize - 1
|
||||
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
end >= fileSize ||
|
||||
start > end
|
||||
) {
|
||||
return new NextResponse(null, { status: 416 })
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1
|
||||
const stream = fs.createReadStream(filePath, { start, end })
|
||||
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": chunkSize.toString(),
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(filePath)
|
||||
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": fileSize.toString(),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to read media file", error)
|
||||
return NextResponse.json({ error: "Unable to read file" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 380 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;display:block;" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
|
||||
<circle cx="50" cy="50" r="34" stroke="#fff4e4" stroke-width="13" stroke-linecap="square" fill="none">
|
||||
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform>
|
||||
<animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="21.362830044410593 192.26547039969535;106.81415022205297 106.81415022205297;21.362830044410593 192.26547039969535" keyTimes="0;0.5;1"></animate>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 704 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="100%" height="100%" viewBox="5.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
|
||||
|
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { ToastBar, Toaster as ToasterRaw } from "react-hot-toast"
|
||||
|
||||
const Toaster = () => (
|
||||
<ToasterRaw>
|
||||
{(t) => (
|
||||
<ToastBar
|
||||
toast={t}
|
||||
style={{
|
||||
...t.style,
|
||||
boxShadow: "rgba(0, 0, 0, 0.25) 0px -4px inset",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{({ icon, message }) => (
|
||||
<>
|
||||
{icon}
|
||||
{message}
|
||||
</>
|
||||
)}
|
||||
</ToastBar>
|
||||
)}
|
||||
</ToasterRaw>
|
||||
)
|
||||
|
||||
export default Toaster
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { Status } from "@rahoot/common/types/game/status"
|
||||
import background from "@rahoot/web/assets/background.webp"
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import Loader from "@rahoot/web/components/Loader"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
|
||||
import clsx from "clsx"
|
||||
import Image from "next/image"
|
||||
import { PropsWithChildren, useEffect, useState } from "react"
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
statusName: Status | undefined
|
||||
onNext?: () => void
|
||||
onPause?: () => void
|
||||
paused?: boolean
|
||||
showPause?: boolean
|
||||
onEnd?: () => void
|
||||
players?: { id: string; username: string; connected: boolean }[]
|
||||
manager?: boolean
|
||||
}
|
||||
|
||||
const GameWrapper = ({
|
||||
children,
|
||||
statusName,
|
||||
onNext,
|
||||
onPause,
|
||||
paused,
|
||||
showPause,
|
||||
onEnd,
|
||||
players,
|
||||
manager,
|
||||
}: Props) => {
|
||||
const { isConnected } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
const { questionStates, setQuestionStates } = useQuestionStore()
|
||||
const [isDisabled, setIsDisabled] = useState(false)
|
||||
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
|
||||
|
||||
useEvent("game:updateQuestion", ({ current, total }) => {
|
||||
setQuestionStates({
|
||||
current,
|
||||
total,
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setIsDisabled(false)
|
||||
}, [statusName])
|
||||
|
||||
const handleNext = () => {
|
||||
setIsDisabled(true)
|
||||
onNext?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-screen w-full flex-col justify-between">
|
||||
<div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
|
||||
<Image
|
||||
className="pointer-events-none h-full w-full object-cover opacity-60"
|
||||
src={background}
|
||||
alt="background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isConnected && !statusName ? (
|
||||
<div className="flex h-full w-full flex-1 flex-col items-center justify-center">
|
||||
<Loader />
|
||||
<h1 className="text-4xl font-bold text-white">Connecting...</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex w-full justify-between p-4">
|
||||
{questionStates && (
|
||||
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
|
||||
{`${questionStates.current} / ${questionStates.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manager && next && (
|
||||
<Button
|
||||
className={clsx("self-end bg-white px-4 text-black!", {
|
||||
"pointer-events-none": isDisabled,
|
||||
})}
|
||||
onClick={handleNext}
|
||||
>
|
||||
{next}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{manager && showPause && (
|
||||
<Button
|
||||
className={clsx("self-end bg-white px-4 text-black!", {
|
||||
"pointer-events-none": isDisabled,
|
||||
})}
|
||||
onClick={onPause}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{manager && onEnd && (
|
||||
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
||||
End game
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manager && players && players.length > 0 && (
|
||||
<div className="mx-4 mb-2 rounded-md bg-white/90 p-3 text-sm shadow">
|
||||
<div className="mb-1 text-xs font-semibold uppercase text-gray-600">
|
||||
Players ({players.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{players.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
"rounded border px-2 py-1 font-semibold",
|
||||
p.connected
|
||||
? "border-green-500 text-green-700"
|
||||
: "border-gray-300 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{p.username || p.id} {p.connected ? "" : "(disc.)"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{!manager && (
|
||||
<div className="z-50 flex items-center justify-between bg-white px-4 py-2 text-lg font-bold text-white">
|
||||
<p className="text-gray-800">{player?.username}</p>
|
||||
<div className="rounded-sm bg-gray-800 px-3 py-1 text-lg">
|
||||
{player?.points}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameWrapper
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
|
||||
import clsx from "clsx"
|
||||
import { useState } from "react"
|
||||
|
||||
type Props = {
|
||||
media?: QuestionMediaType
|
||||
alt: string
|
||||
onPlayChange?: (_playing: boolean) => void
|
||||
}
|
||||
|
||||
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||
const [zoomed, setZoomed] = useState(false)
|
||||
|
||||
if (!media) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerClass = "mx-auto flex w-full max-w-5xl justify-center"
|
||||
|
||||
switch (media.type) {
|
||||
case "image":
|
||||
return (
|
||||
<>
|
||||
<div className={containerClass}>
|
||||
<img
|
||||
alt={alt}
|
||||
src={media.url}
|
||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full cursor-zoom-in rounded-md object-contain shadow-lg"
|
||||
onClick={() => setZoomed(true)}
|
||||
/>
|
||||
</div>
|
||||
{zoomed && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={() => setZoomed(false)}
|
||||
>
|
||||
<img
|
||||
src={media.url}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-md shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div className={clsx(containerClass, "px-4")}>
|
||||
<audio
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
src={media.url}
|
||||
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
|
||||
preload="none"
|
||||
onPlay={() => onPlayChange?.(true)}
|
||||
onPause={() => onPlayChange?.(false)}
|
||||
onEnded={() => onPlayChange?.(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "video":
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<video
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
playsInline
|
||||
src={media.url}
|
||||
className="m-4 w-full max-w-5xl rounded-md shadow-lg"
|
||||
preload="metadata"
|
||||
onPlay={() => onPlayChange?.(true)}
|
||||
onPause={() => onPlayChange?.(false)}
|
||||
onEnded={() => onPlayChange?.(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default QuestionMedia
|
||||
@@ -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,147 @@
|
||||
"use client"
|
||||
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type MediaItem = {
|
||||
fileName: string
|
||||
url: string
|
||||
size: number
|
||||
mime: string
|
||||
type: string
|
||||
usedBy: {
|
||||
quizzId: string
|
||||
subject: string
|
||||
questionIndex: number
|
||||
question: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes) return "0 B"
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** i
|
||||
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const MediaLibrary = () => {
|
||||
const [items, setItems] = useState<MediaItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/media", { cache: "no-store" })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load media")
|
||||
setItems(data.media || [])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (fileName: string) => {
|
||||
setDeleting((prev) => ({ ...prev, [fileName]: true }))
|
||||
try {
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Failed to delete file")
|
||||
load()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert(error instanceof Error ? error.message : "Failed to delete")
|
||||
} finally {
|
||||
setDeleting((prev) => ({ ...prev, [fileName]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Media library</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Uploaded files with their usage. Delete is enabled only when unused.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="bg-gray-700" onClick={load} disabled={loading}>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase text-gray-500">
|
||||
<th className="p-2">File</th>
|
||||
<th className="p-2">Type</th>
|
||||
<th className="p-2">Size</th>
|
||||
<th className="p-2">Used by</th>
|
||||
<th className="p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.fileName} className="border-b border-gray-100">
|
||||
<td className="p-2 font-semibold text-gray-800">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{item.fileName}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-2">{item.type}</td>
|
||||
<td className="p-2 text-gray-600">{formatBytes(item.size)}</td>
|
||||
<td className="p-2">
|
||||
{item.usedBy.length === 0 ? (
|
||||
<span className="text-green-700">Unused</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{item.usedBy.map((u, idx) => (
|
||||
<div key={idx} className="text-gray-700">
|
||||
<span className="font-semibold">{u.subject || u.quizzId}</span>
|
||||
{` – Q${u.questionIndex + 1}: ${u.question}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Button
|
||||
className="bg-red-500 px-3 py-1 text-sm"
|
||||
onClick={() => handleDelete(item.fileName)}
|
||||
disabled={item.usedBy.length > 0 || deleting[item.fileName]}
|
||||
>
|
||||
{deleting[item.fileName] ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-sm text-gray-500" colSpan={5}>
|
||||
No media uploaded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaLibrary
|
||||
@@ -0,0 +1,791 @@
|
||||
"use client"
|
||||
|
||||
import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import Input from "@rahoot/web/components/Input"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import clsx from "clsx"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
type Props = {
|
||||
quizzList: QuizzWithId[]
|
||||
onBack: () => void
|
||||
onListUpdate: (_quizz: QuizzWithId[]) => void
|
||||
}
|
||||
|
||||
type EditableQuestion = QuizzWithId["questions"][number]
|
||||
|
||||
type MediaLibraryItem = {
|
||||
fileName: string
|
||||
url: string
|
||||
size: number
|
||||
mime: string
|
||||
type: QuestionMedia["type"]
|
||||
usedBy: {
|
||||
quizzId: string
|
||||
subject: string
|
||||
questionIndex: number
|
||||
question: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const blankQuestion = (): EditableQuestion => ({
|
||||
question: "",
|
||||
answers: ["", ""],
|
||||
solution: 0,
|
||||
cooldown: 5,
|
||||
time: 20,
|
||||
})
|
||||
|
||||
const mediaTypes: QuestionMedia["type"][] = ["image", "audio", "video"]
|
||||
|
||||
const acceptByType: Record<QuestionMedia["type"], string> = {
|
||||
image: "image/*",
|
||||
audio: "audio/*",
|
||||
video: "video/*",
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes) return "0 B"
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** i
|
||||
|
||||
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
const { socket } = useSocket()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<QuizzWithId | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mediaLibrary, setMediaLibrary] = useState<MediaLibraryItem[]>([])
|
||||
const [uploading, setUploading] = useState<Record<number, boolean>>({})
|
||||
const [deleting, setDeleting] = useState<Record<number, boolean>>({})
|
||||
const [refreshingLibrary, setRefreshingLibrary] = useState(false)
|
||||
const [probing, setProbing] = useState<Record<number, boolean>>({})
|
||||
|
||||
useEvent("manager:quizzLoaded", (quizz) => {
|
||||
setDraft(quizz)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
useEvent("manager:quizzSaved", (quizz) => {
|
||||
toast.success("Quiz saved")
|
||||
setDraft(quizz)
|
||||
setSelectedId(quizz.id)
|
||||
setSaving(false)
|
||||
refreshMediaLibrary()
|
||||
})
|
||||
|
||||
useEvent("manager:quizzDeleted", (id) => {
|
||||
toast.success("Quiz deleted")
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null)
|
||||
setDraft(null)
|
||||
}
|
||||
refreshMediaLibrary()
|
||||
})
|
||||
|
||||
useEvent("manager:quizzList", (list) => {
|
||||
onListUpdate(list)
|
||||
})
|
||||
|
||||
useEvent("manager:errorMessage", (message) => {
|
||||
toast.error(message)
|
||||
setSaving(false)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
const refreshMediaLibrary = useCallback(async () => {
|
||||
setRefreshingLibrary(true)
|
||||
try {
|
||||
const res = await fetch("/api/media", { cache: "no-store" })
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to load media library")
|
||||
}
|
||||
|
||||
setMediaLibrary(data.media || [])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch media library", error)
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to load media library",
|
||||
)
|
||||
} finally {
|
||||
setRefreshingLibrary(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshMediaLibrary()
|
||||
}, [refreshMediaLibrary])
|
||||
|
||||
const handleLoad = (id: string) => {
|
||||
setSelectedId(id)
|
||||
setLoading(true)
|
||||
socket?.emit("manager:getQuizz", id)
|
||||
}
|
||||
|
||||
const handleNew = () => {
|
||||
setSelectedId(null)
|
||||
setDraft({
|
||||
id: "",
|
||||
subject: "",
|
||||
questions: [blankQuestion()],
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteQuizz = () => {
|
||||
if (!selectedId) return
|
||||
if (!window.confirm("Delete this quiz?")) return
|
||||
setSaving(true)
|
||||
socket?.emit("manager:deleteQuizz", { id: selectedId })
|
||||
}
|
||||
|
||||
const updateQuestion = (
|
||||
index: number,
|
||||
patch: Partial<EditableQuestion>,
|
||||
) => {
|
||||
if (!draft) return
|
||||
const nextQuestions = [...draft.questions]
|
||||
nextQuestions[index] = { ...nextQuestions[index], ...patch }
|
||||
setDraft({ ...draft, questions: nextQuestions })
|
||||
}
|
||||
|
||||
const updateAnswer = (qIndex: number, aIndex: number, value: string) => {
|
||||
if (!draft) return
|
||||
const nextQuestions = [...draft.questions]
|
||||
const nextAnswers = [...nextQuestions[qIndex].answers]
|
||||
nextAnswers[aIndex] = value
|
||||
nextQuestions[qIndex] = { ...nextQuestions[qIndex], answers: nextAnswers }
|
||||
setDraft({ ...draft, questions: nextQuestions })
|
||||
}
|
||||
|
||||
const addAnswer = (qIndex: number) => {
|
||||
if (!draft) return
|
||||
const nextQuestions = [...draft.questions]
|
||||
if (nextQuestions[qIndex].answers.length >= 4) {
|
||||
return
|
||||
}
|
||||
nextQuestions[qIndex] = {
|
||||
...nextQuestions[qIndex],
|
||||
answers: [...nextQuestions[qIndex].answers, ""],
|
||||
}
|
||||
setDraft({ ...draft, questions: nextQuestions })
|
||||
}
|
||||
|
||||
const removeAnswer = (qIndex: number, aIndex: number) => {
|
||||
if (!draft) return
|
||||
const nextQuestions = [...draft.questions]
|
||||
const currentAnswers = [...nextQuestions[qIndex].answers]
|
||||
if (currentAnswers.length <= 2) {
|
||||
return
|
||||
}
|
||||
currentAnswers.splice(aIndex, 1)
|
||||
let nextSolution = nextQuestions[qIndex].solution
|
||||
if (nextSolution >= currentAnswers.length) {
|
||||
nextSolution = currentAnswers.length - 1
|
||||
}
|
||||
nextQuestions[qIndex] = {
|
||||
...nextQuestions[qIndex],
|
||||
answers: currentAnswers,
|
||||
solution: nextSolution,
|
||||
}
|
||||
setDraft({ ...draft, questions: nextQuestions })
|
||||
}
|
||||
|
||||
const addQuestion = () => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, questions: [...draft.questions, blankQuestion()] })
|
||||
}
|
||||
|
||||
const removeQuestion = (index: number) => {
|
||||
if (!draft || draft.questions.length <= 1) return
|
||||
const nextQuestions = draft.questions.filter((_, i) => i !== index)
|
||||
setDraft({ ...draft, questions: nextQuestions })
|
||||
}
|
||||
|
||||
const setQuestionMedia = (qIndex: number, media?: QuestionMedia) => {
|
||||
if (!draft) return
|
||||
updateQuestion(qIndex, {
|
||||
media,
|
||||
image: media?.type === "image" ? media.url : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const getMediaFileName = (media?: QuestionMedia | null) => {
|
||||
if (!media) return null
|
||||
if (media.fileName) return media.fileName
|
||||
if (media.url?.startsWith("/media/")) {
|
||||
return decodeURIComponent(media.url.split("/").pop() || "")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getLibraryEntry = (media?: QuestionMedia | null) => {
|
||||
const fileName = getMediaFileName(media)
|
||||
if (!fileName) return null
|
||||
|
||||
return mediaLibrary.find((item) => item.fileName === fileName) || null
|
||||
}
|
||||
|
||||
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
|
||||
if (!draft) return
|
||||
const question = draft.questions[qIndex]
|
||||
|
||||
if (type === "") {
|
||||
setQuestionMedia(qIndex, undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const nextMedia =
|
||||
question.media?.type === type
|
||||
? { ...question.media, type }
|
||||
: { type, url: "" }
|
||||
|
||||
setQuestionMedia(qIndex, nextMedia)
|
||||
}
|
||||
|
||||
const handleMediaUrlChange = (qIndex: number, url: string) => {
|
||||
if (!draft) return
|
||||
const question = draft.questions[qIndex]
|
||||
|
||||
if (!question.media?.type) {
|
||||
toast.error("Select a media type before setting a URL")
|
||||
return
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
setQuestionMedia(qIndex, undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const nextMedia: QuestionMedia = {
|
||||
type: question.media.type,
|
||||
url,
|
||||
}
|
||||
|
||||
if (question.media.fileName && url.includes(question.media.fileName)) {
|
||||
nextMedia.fileName = question.media.fileName
|
||||
}
|
||||
|
||||
setQuestionMedia(qIndex, nextMedia)
|
||||
}
|
||||
|
||||
const clearQuestionMedia = (qIndex: number) => {
|
||||
setQuestionMedia(qIndex, undefined)
|
||||
}
|
||||
|
||||
const probeMediaDuration = async (url: string, type: QuestionMedia["type"]) => {
|
||||
if (!url || (type !== "audio" && type !== "video")) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const el = document.createElement(type)
|
||||
el.crossOrigin = "anonymous"
|
||||
el.preload = "metadata"
|
||||
el.src = url
|
||||
el.load()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
el.onloadedmetadata = null
|
||||
el.onloadeddata = null
|
||||
el.oncanplaythrough = null
|
||||
el.onerror = null
|
||||
}
|
||||
const done = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
el.onloadedmetadata = done
|
||||
el.onloadeddata = done
|
||||
el.oncanplaythrough = done
|
||||
el.onerror = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to load media metadata"))
|
||||
}
|
||||
// safety timeout
|
||||
setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("Timed out loading media metadata"))
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
const duration = el.duration
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null
|
||||
} catch (error) {
|
||||
console.warn("Failed to probe media duration", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const adjustTimingWithMedia = async (
|
||||
qIndex: number,
|
||||
media: QuestionMedia | undefined,
|
||||
) => {
|
||||
if (!draft || !media?.url || !media.type || media.type === "image") {
|
||||
return
|
||||
}
|
||||
|
||||
setProbing((prev) => ({ ...prev, [qIndex]: true }))
|
||||
|
||||
try {
|
||||
const duration = await probeMediaDuration(media.url, media.type)
|
||||
if (!duration || !draft) {
|
||||
return
|
||||
}
|
||||
|
||||
const rounded = Math.ceil(duration)
|
||||
const buffer = 3
|
||||
const minCooldown = rounded
|
||||
const minAnswer = rounded + buffer
|
||||
const question = draft.questions[qIndex]
|
||||
|
||||
const nextCooldown = Math.max(question.cooldown, minCooldown)
|
||||
const nextTime = Math.max(question.time, minAnswer)
|
||||
|
||||
if (nextCooldown !== question.cooldown || nextTime !== question.time) {
|
||||
updateQuestion(qIndex, {
|
||||
cooldown: nextCooldown,
|
||||
time: nextTime,
|
||||
})
|
||||
toast.success(
|
||||
`Adjusted timing to media length (~${rounded}s, answers ${nextTime}s)`,
|
||||
{ id: `timing-${qIndex}` },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setProbing((prev) => ({ ...prev, [qIndex]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMediaUpload = async (qIndex: number, file: File) => {
|
||||
if (!draft) return
|
||||
const question = draft.questions[qIndex]
|
||||
|
||||
if (!question.media?.type) {
|
||||
toast.error("Select a media type before uploading")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading((prev) => ({ ...prev, [qIndex]: true }))
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
const res = await fetch("/api/media", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to upload media")
|
||||
}
|
||||
|
||||
const uploaded = data.media as MediaLibraryItem
|
||||
const type = uploaded.type
|
||||
|
||||
setQuestionMedia(qIndex, {
|
||||
type,
|
||||
url: uploaded.url,
|
||||
fileName: uploaded.fileName,
|
||||
})
|
||||
toast.success("Media uploaded")
|
||||
refreshMediaLibrary()
|
||||
} catch (error) {
|
||||
console.error("Upload failed", error)
|
||||
toast.error(error instanceof Error ? error.message : "Upload failed")
|
||||
} finally {
|
||||
setUploading((prev) => ({ ...prev, [qIndex]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMediaFile = async (qIndex: number) => {
|
||||
if (!draft) return
|
||||
const question = draft.questions[qIndex]
|
||||
const fileName = getMediaFileName(question.media)
|
||||
|
||||
if (!fileName) {
|
||||
toast.error("No stored file to delete")
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting((prev) => ({ ...prev, [qIndex]: true }))
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to delete file")
|
||||
}
|
||||
|
||||
toast.success("File deleted")
|
||||
clearQuestionMedia(qIndex)
|
||||
refreshMediaLibrary()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file", error)
|
||||
toast.error(error instanceof Error ? error.message : "Failed to delete file")
|
||||
} finally {
|
||||
setDeleting((prev) => ({ ...prev, [qIndex]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft) return
|
||||
setSaving(true)
|
||||
socket?.emit("manager:saveQuizz", {
|
||||
id: draft.id || null,
|
||||
quizz: {
|
||||
subject: draft.subject,
|
||||
questions: draft.questions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!selectedId) return "New quiz"
|
||||
const found = quizzList.find((q) => q.id === selectedId)
|
||||
return found ? `Editing: ${found.subject}` : `Editing: ${selectedId}`
|
||||
}, [quizzList, selectedId])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onBack} className="bg-gray-700">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNew} className="bg-blue-600">
|
||||
New quiz
|
||||
</Button>
|
||||
{selectedId && (
|
||||
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
||||
Delete quiz
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? "Saving..." : "Save quiz"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-600">
|
||||
Existing quizzes:
|
||||
</span>
|
||||
{quizzList.map((quizz) => (
|
||||
<button
|
||||
key={quizz.id}
|
||||
onClick={() => handleLoad(quizz.id)}
|
||||
className={clsx(
|
||||
"rounded-sm border px-3 py-1 text-sm font-semibold",
|
||||
selectedId === quizz.id
|
||||
? "border-primary text-primary"
|
||||
: "border-gray-300",
|
||||
)}
|
||||
>
|
||||
{quizz.subject}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!draft && (
|
||||
<div className="rounded-md border border-dashed border-gray-300 p-6 text-center text-gray-600">
|
||||
{loading ? "Loading quiz..." : "Select a quiz to edit or create a new one."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-gray-200 p-4">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-700">
|
||||
{selectedLabel}
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-gray-600">Subject</span>
|
||||
<Input
|
||||
value={draft.subject}
|
||||
onChange={(e) => setDraft({ ...draft, subject: e.target.value })}
|
||||
placeholder="Quiz title"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.questions.map((question, qIndex) => {
|
||||
const libraryEntry = getLibraryEntry(question.media)
|
||||
const mediaFileName = getMediaFileName(question.media)
|
||||
const isUploading = uploading[qIndex]
|
||||
const isDeleting = deleting[qIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={qIndex}
|
||||
className="rounded-md border border-gray-200 p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-gray-800">
|
||||
Question {qIndex + 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-red-500"
|
||||
onClick={() => removeQuestion(qIndex)}
|
||||
disabled={draft.questions.length <= 1}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-gray-600">Prompt</span>
|
||||
<Input
|
||||
value={question.question}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, { question: e.target.value })
|
||||
}
|
||||
placeholder="Enter the question"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-gray-600">
|
||||
Cooldown (s)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={question.cooldown}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, {
|
||||
cooldown: Number(e.target.value || 0),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-gray-600">
|
||||
Answer time (s)
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={question.time}
|
||||
onChange={(e) =>
|
||||
updateQuestion(qIndex, { time: Number(e.target.value || 0) })
|
||||
}
|
||||
min={5}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-gray-600">
|
||||
Media type
|
||||
</span>
|
||||
<select
|
||||
className="rounded-sm border border-gray-300 p-2 font-semibold"
|
||||
value={question.media?.type || ""}
|
||||
onChange={(e) =>
|
||||
handleMediaType(qIndex, e.target.value as QuestionMedia["type"] | "")
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{mediaTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-600">
|
||||
<span>Media upload</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: probing[qIndex]
|
||||
? "Probing..."
|
||||
: refreshingLibrary
|
||||
? "Refreshing..."
|
||||
: mediaFileName
|
||||
? "Stored"
|
||||
: "Not saved"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept={
|
||||
question.media?.type ? acceptByType[question.media.type] : undefined
|
||||
}
|
||||
disabled={!question.media?.type || isUploading}
|
||||
className="rounded-sm border border-dashed border-gray-300 p-2 text-sm"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleMediaUpload(qIndex, file)
|
||||
e.target.value = ""
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Files are stored locally and served from /media. Pick a type first.
|
||||
</p>
|
||||
|
||||
{question.media && (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-700">
|
||||
<span>
|
||||
{mediaFileName || question.media.url || "No file yet"}
|
||||
</span>
|
||||
{libraryEntry && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatBytes(libraryEntry.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{libraryEntry
|
||||
? `Used in ${libraryEntry.usedBy.length} question${
|
||||
libraryEntry.usedBy.length === 1 ? "" : "s"
|
||||
}`
|
||||
: question.media.url
|
||||
? "External media URL"
|
||||
: "Upload a file or paste a URL"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-600">
|
||||
Or paste an external URL
|
||||
</span>
|
||||
<Input
|
||||
value={question.media?.url || question.image || ""}
|
||||
onChange={(e) => handleMediaUrlChange(qIndex, e.target.value)}
|
||||
placeholder="https://..."
|
||||
disabled={!question.media?.type}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
Tip: set answer time longer than the clip duration.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{question.media?.type !== "image" && question.media?.url && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="bg-gray-800"
|
||||
onClick={() => adjustTimingWithMedia(qIndex, question.media)}
|
||||
disabled={probing[qIndex]}
|
||||
>
|
||||
{probing[qIndex] ? "Probing..." : "Set timing from media"}
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
Probes audio/video duration and bumps cooldown/answer time if needed.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-gray-700"
|
||||
onClick={() => clearQuestionMedia(qIndex)}
|
||||
disabled={!question.media}
|
||||
>
|
||||
Clear from question
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-500"
|
||||
onClick={() => handleDeleteMediaFile(qIndex)}
|
||||
disabled={!mediaFileName || isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete file"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">Answers</span>
|
||||
<Button
|
||||
className="bg-blue-600"
|
||||
onClick={() => addAnswer(qIndex)}
|
||||
disabled={question.answers.length >= 4}
|
||||
>
|
||||
Add answer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{question.answers.map((answer, aIndex) => (
|
||||
<div
|
||||
key={aIndex}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
question.solution === aIndex
|
||||
? "border-green-500"
|
||||
: "border-gray-200",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${qIndex}`}
|
||||
checked={question.solution === aIndex}
|
||||
onChange={() =>
|
||||
updateQuestion(qIndex, { solution: aIndex })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={answer}
|
||||
onChange={(e) =>
|
||||
updateAnswer(qIndex, aIndex, e.target.value)
|
||||
}
|
||||
placeholder={`Answer ${aIndex + 1}`}
|
||||
/>
|
||||
<button
|
||||
className="rounded-sm px-2 py-1 text-sm font-semibold text-red-500"
|
||||
onClick={() => removeAnswer(qIndex, aIndex)}
|
||||
disabled={question.answers.length <= 2}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button className="bg-blue-600" onClick={addQuestion}>
|
||||
Add question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizEditor
|
||||
@@ -0,0 +1,86 @@
|
||||
import { QuizzWithId } from "@rahoot/common/types/game"
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import clsx from "clsx"
|
||||
import { useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
type Props = {
|
||||
quizzList: QuizzWithId[]
|
||||
onSelect: (_id: string) => void
|
||||
onManage?: () => void
|
||||
onMedia?: () => void
|
||||
}
|
||||
|
||||
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const handleSelect = (id: string) => () => {
|
||||
if (selected === id) {
|
||||
setSelected(null)
|
||||
} else {
|
||||
setSelected(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selected) {
|
||||
toast.error("Please select a quizz")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{onMedia && (
|
||||
<button
|
||||
className="text-sm font-semibold text-gray-700 underline"
|
||||
onClick={onMedia}
|
||||
>
|
||||
Media
|
||||
</button>
|
||||
)}
|
||||
{onManage && (
|
||||
<button
|
||||
className="text-sm font-semibold text-primary underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-full space-y-2">
|
||||
{quizzList.map((quizz) => (
|
||||
<button
|
||||
key={quizz.id}
|
||||
className={clsx(
|
||||
"flex w-full items-center justify-between rounded-md p-3 outline outline-gray-300",
|
||||
)}
|
||||
onClick={handleSelect(quizz.id)}
|
||||
>
|
||||
{quizz.subject}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"h-5 w-5 rounded outline outline-offset-3 outline-gray-300",
|
||||
selected === quizz.id &&
|
||||
"bg-primary border-primary/80 shadow-inset",
|
||||
)}
|
||||
></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectQuizz
|
||||
@@ -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,56 @@
|
||||
"use client"
|
||||
|
||||
import { STATUS } from "@rahoot/common/types/game/status"
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import Form from "@rahoot/web/components/Form"
|
||||
import Input from "@rahoot/web/components/Input"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { KeyboardEvent, useState } from "react"
|
||||
|
||||
const Username = () => {
|
||||
const { socket } = useSocket()
|
||||
const { gameId, login, setStatus } = usePlayerStore()
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState("")
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!gameId) {
|
||||
return
|
||||
}
|
||||
|
||||
socket?.emit("player:login", { gameId, data: { username } })
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
|
||||
useEvent("game:successJoin", (gameId) => {
|
||||
setStatus(STATUS.WAIT, { text: "Waiting for the players" })
|
||||
login(username)
|
||||
try {
|
||||
localStorage.setItem("last_game_id", gameId)
|
||||
localStorage.setItem("last_username", username)
|
||||
} catch {}
|
||||
|
||||
router.replace(`/game/${gameId}`)
|
||||
})
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Input
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Username here"
|
||||
/>
|
||||
<Button onClick={handleLogin}>Submit</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Username
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
||||
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import {
|
||||
ANSWERS_COLORS,
|
||||
ANSWERS_ICONS,
|
||||
SFX_ANSWERS_MUSIC,
|
||||
SFX_ANSWERS_SOUND,
|
||||
} from "@rahoot/web/utils/constants"
|
||||
import clsx from "clsx"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: CommonStatusDataMap["SELECT_ANSWER"]
|
||||
}
|
||||
|
||||
const Answers = ({
|
||||
data: { question, answers, image, media, time, totalPlayer },
|
||||
}: Props) => {
|
||||
const { gameId }: { gameId?: string } = useParams()
|
||||
const { socket } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
|
||||
const [cooldown, setCooldown] = useState(time)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||
|
||||
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
||||
volume: 0.1,
|
||||
})
|
||||
|
||||
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
|
||||
SFX_ANSWERS_MUSIC,
|
||||
{
|
||||
volume: 0.2,
|
||||
interrupt: true,
|
||||
loop: true,
|
||||
},
|
||||
)
|
||||
|
||||
const handleAnswer = (answerKey: number) => () => {
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
socket?.emit("player:selectedAnswer", {
|
||||
gameId,
|
||||
data: {
|
||||
answerKey,
|
||||
},
|
||||
})
|
||||
sfxPop()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
playMusic()
|
||||
|
||||
return () => {
|
||||
stopMusic()
|
||||
}
|
||||
}, [playMusic])
|
||||
|
||||
useEffect(() => {
|
||||
if (!answersMusic) {
|
||||
return
|
||||
}
|
||||
|
||||
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
|
||||
}, [answersMusic, isMediaPlaying])
|
||||
|
||||
useEvent("game:cooldown", (sec) => {
|
||||
setCooldown(sec)
|
||||
})
|
||||
|
||||
useEvent("game:cooldownPause", (isPaused) => {
|
||||
setPaused(isPaused)
|
||||
})
|
||||
|
||||
useEvent("game:playerAnswer", (count) => {
|
||||
setTotalAnswer(count)
|
||||
sfxPop()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{question}
|
||||
</h2>
|
||||
|
||||
<QuestionMedia
|
||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||
alt={question}
|
||||
onPlayChange={(playing) => setIsMediaPlaying(playing)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mx-auto mb-4 flex w-full max-w-7xl justify-between gap-1 px-2 text-lg font-bold text-white md:text-xl">
|
||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
||||
<span className="translate-y-1 text-sm">Time</span>
|
||||
<span>{cooldown}</span>
|
||||
{paused && (
|
||||
<span className="text-xs font-semibold uppercase text-amber-200">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
||||
<span className="translate-y-1 text-sm">Answers</span>
|
||||
<span>
|
||||
{totalAnswer}/{totalPlayer}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
|
||||
{answers.map((answer, key) => (
|
||||
<AnswerButton
|
||||
key={key}
|
||||
className={clsx(ANSWERS_COLORS[key])}
|
||||
icon={ANSWERS_ICONS[key]}
|
||||
onClick={handleAnswer(key)}
|
||||
>
|
||||
{answer}
|
||||
</AnswerButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Answers
|
||||
@@ -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
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import useScreenSize from "@rahoot/web/hooks/useScreenSize"
|
||||
import {
|
||||
SFX_PODIUM_FIRST,
|
||||
SFX_PODIUM_SECOND,
|
||||
SFX_PODIUM_THREE,
|
||||
SFX_SNEAR_ROOL,
|
||||
} from "@rahoot/web/utils/constants"
|
||||
import clsx from "clsx"
|
||||
import { useEffect, useState } from "react"
|
||||
import ReactConfetti from "react-confetti"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: ManagerStatusDataMap["FINISHED"]
|
||||
}
|
||||
|
||||
const Podium = ({ data: { subject, top } }: Props) => {
|
||||
const [apparition, setApparition] = useState(0)
|
||||
|
||||
const { width, height } = useScreenSize()
|
||||
|
||||
const [sfxtThree] = useSound(SFX_PODIUM_THREE, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
const [sfxSecond] = useSound(SFX_PODIUM_SECOND, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
const [sfxRool, { stop: sfxRoolStop }] = useSound(SFX_SNEAR_ROOL, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
const [sfxFirst] = useSound(SFX_PODIUM_FIRST, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
switch (apparition) {
|
||||
case 4:
|
||||
sfxRoolStop()
|
||||
sfxFirst()
|
||||
|
||||
break
|
||||
|
||||
case 3:
|
||||
sfxRool()
|
||||
|
||||
break
|
||||
|
||||
case 2:
|
||||
sfxSecond()
|
||||
|
||||
break
|
||||
|
||||
case 1:
|
||||
sfxtThree()
|
||||
|
||||
break
|
||||
}
|
||||
}, [apparition, sfxFirst, sfxSecond, sfxtThree, sfxRool, sfxRoolStop])
|
||||
|
||||
useEffect(() => {
|
||||
if (top.length < 3) {
|
||||
setApparition(4)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (apparition > 4) {
|
||||
clearInterval(interval)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setApparition((value) => value + 1)
|
||||
}, 2000)
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearInterval(interval)
|
||||
}, [apparition, top.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
{apparition >= 4 && (
|
||||
<ReactConfetti
|
||||
width={width}
|
||||
height={height}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apparition >= 3 && top.length >= 3 && (
|
||||
<div className="pointer-events-none absolute min-h-screen w-full overflow-hidden">
|
||||
<div className="spotlight"></div>
|
||||
</div>
|
||||
)}
|
||||
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between">
|
||||
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{subject}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{ gridTemplateColumns: `repeat(${top.length}, 1fr)` }}
|
||||
className={`grid w-full max-w-[800px] flex-1 items-end justify-center justify-self-end overflow-x-visible overflow-y-hidden`}
|
||||
>
|
||||
{top[1] && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-20 flex h-[50%] w-full translate-y-full flex-col items-center justify-center gap-3 opacity-0 transition-all",
|
||||
{ "translate-y-0! opacity-100": apparition >= 2 },
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
|
||||
{
|
||||
"anim-balanced": apparition >= 4,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{top[1].username}
|
||||
</p>
|
||||
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
|
||||
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-zinc-400 bg-zinc-500 text-3xl font-bold text-white drop-shadow-lg">
|
||||
<span className="drop-shadow-md">2</span>
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-white drop-shadow-lg">
|
||||
{top[1].points}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"z-30 flex h-[60%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
|
||||
{
|
||||
"translate-y-0! opacity-100": apparition >= 3,
|
||||
},
|
||||
{
|
||||
"md:min-w-64": top.length < 2,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white opacity-0 drop-shadow-lg md:text-4xl",
|
||||
{ "anim-balanced opacity-100": apparition >= 4 },
|
||||
)}
|
||||
>
|
||||
{top[0].username}
|
||||
</p>
|
||||
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
|
||||
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-400 bg-amber-300 text-3xl font-bold text-white drop-shadow-lg">
|
||||
<span className="drop-shadow-md">1</span>
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-white drop-shadow-lg">
|
||||
{top[0].points}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{top[2] && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 flex h-[40%] w-full translate-y-full flex-col items-center gap-3 opacity-0 transition-all",
|
||||
{
|
||||
"translate-y-0! opacity-100": apparition >= 1,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
"overflow-visible text-center text-2xl font-bold whitespace-nowrap text-white drop-shadow-lg md:text-4xl",
|
||||
{
|
||||
"anim-balanced": apparition >= 4,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{top[2].username}
|
||||
</p>
|
||||
<div className="bg-primary flex h-full w-full flex-col items-center gap-4 rounded-t-md pt-6 text-center shadow-2xl">
|
||||
<p className="flex aspect-square h-14 items-center justify-center rounded-full border-4 border-amber-800 bg-amber-700 text-3xl font-bold text-white drop-shadow-lg">
|
||||
<span className="drop-shadow-md">3</span>
|
||||
</p>
|
||||
|
||||
<p className="text-2xl font-bold text-white drop-shadow-lg">
|
||||
{top[2].points}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Podium
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||
import { useEvent } from "@rahoot/web/contexts/socketProvider"
|
||||
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
|
||||
import { useEffect, useState } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: CommonStatusDataMap["SHOW_QUESTION"]
|
||||
}
|
||||
|
||||
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
||||
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
|
||||
const [seconds, setSeconds] = useState(cooldown)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
sfxShow()
|
||||
}, [sfxShow])
|
||||
|
||||
useEvent("game:cooldown", (sec) => {
|
||||
setSeconds(sec)
|
||||
})
|
||||
|
||||
useEvent("game:cooldownPause", (isPaused) => {
|
||||
setPaused(isPaused)
|
||||
})
|
||||
|
||||
const percent = Math.max(0, Math.min(100, (seconds / cooldown) * 100))
|
||||
|
||||
return (
|
||||
<section className="relative mx-auto flex h-full w-full max-w-7xl flex-1 flex-col items-center px-4">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5">
|
||||
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{question}
|
||||
</h2>
|
||||
|
||||
<QuestionMedia
|
||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||
alt={question}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-20 h-4 w-full max-w-4xl self-start overflow-hidden rounded-full bg-white/30">
|
||||
<div
|
||||
className="h-full bg-primary transition-[width]"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{paused && (
|
||||
<div className="absolute bottom-6 right-6 rounded-md bg-black/60 px-3 py-1 text-sm font-semibold text-white">
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Question
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import AnswerButton from "@rahoot/web/components/AnswerButton"
|
||||
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||
import {
|
||||
ANSWERS_COLORS,
|
||||
ANSWERS_ICONS,
|
||||
SFX_ANSWERS_MUSIC,
|
||||
SFX_RESULTS_SOUND,
|
||||
} from "@rahoot/web/utils/constants"
|
||||
import { calculatePercentages } from "@rahoot/web/utils/score"
|
||||
import clsx from "clsx"
|
||||
import { useEffect, useState } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: ManagerStatusDataMap["SHOW_RESPONSES"]
|
||||
}
|
||||
|
||||
const Responses = ({
|
||||
data: { question, answers, responses, correct, image, media },
|
||||
}: Props) => {
|
||||
const [percentages, setPercentages] = useState<Record<string, string>>({})
|
||||
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
|
||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||
|
||||
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
|
||||
SFX_ANSWERS_MUSIC,
|
||||
{
|
||||
volume: 0.2,
|
||||
onplay: () => {
|
||||
setIsMusicPlaying(true)
|
||||
},
|
||||
onend: () => {
|
||||
setIsMusicPlaying(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
stopMusic()
|
||||
sfxResults()
|
||||
|
||||
setPercentages(calculatePercentages(responses))
|
||||
}, [responses, playMusic, stopMusic, sfxResults])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMusicPlaying) {
|
||||
playMusic()
|
||||
}
|
||||
}, [isMusicPlaying, playMusic])
|
||||
|
||||
useEffect(() => {
|
||||
if (!answersMusic) {
|
||||
return
|
||||
}
|
||||
|
||||
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
|
||||
}, [answersMusic, isMediaPlaying])
|
||||
|
||||
useEffect(() => {
|
||||
stopMusic()
|
||||
}, [playMusic, stopMusic])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{question}
|
||||
</h2>
|
||||
|
||||
<QuestionMedia
|
||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||
alt={question}
|
||||
onPlayChange={(playing) => setIsMediaPlaying(playing)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
|
||||
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
|
||||
>
|
||||
{answers.map((_, key) => (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
"flex flex-col justify-end self-end overflow-hidden rounded-md",
|
||||
ANSWERS_COLORS[key],
|
||||
)}
|
||||
style={{ height: percentages[key] }}
|
||||
>
|
||||
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md">
|
||||
{responses[key] || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
|
||||
{answers.map((answer, key) => (
|
||||
<AnswerButton
|
||||
key={key}
|
||||
className={clsx(ANSWERS_COLORS[key], {
|
||||
"opacity-65": responses && correct !== key,
|
||||
})}
|
||||
icon={ANSWERS_ICONS[key]}
|
||||
>
|
||||
{answer}
|
||||
</AnswerButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Responses
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import CricleCheck from "@rahoot/web/components/icons/CricleCheck"
|
||||
import CricleXmark from "@rahoot/web/components/icons/CricleXmark"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { SFX_RESULTS_SOUND } from "@rahoot/web/utils/constants"
|
||||
import { useEffect } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: CommonStatusDataMap["SHOW_RESULT"]
|
||||
}
|
||||
|
||||
const Result = ({
|
||||
data: { correct, message, points, myPoints, rank, aheadOfMe },
|
||||
}: Props) => {
|
||||
const player = usePlayerStore()
|
||||
|
||||
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
player.updatePoints(myPoints)
|
||||
|
||||
sfxResults()
|
||||
}, [sfxResults])
|
||||
|
||||
return (
|
||||
<section className="anim-show relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
|
||||
{correct ? (
|
||||
<CricleCheck className="aspect-square max-h-60 w-full" />
|
||||
) : (
|
||||
<CricleXmark className="aspect-square max-h-60 w-full" />
|
||||
)}
|
||||
<h2 className="mt-1 text-4xl font-bold text-white drop-shadow-lg">
|
||||
{message}
|
||||
</h2>
|
||||
<p className="mt-1 text-xl font-bold text-white drop-shadow-lg">
|
||||
{`You are top ${rank}${aheadOfMe ? `, behind ${aheadOfMe}` : ""}`}
|
||||
</p>
|
||||
{correct && (
|
||||
<span className="mt-2 rounded bg-black/40 px-4 py-2 text-2xl font-bold text-white drop-shadow-lg">
|
||||
+{points}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Result
|
||||
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import { useEvent } from "@rahoot/web/contexts/socketProvider"
|
||||
import { SFX_BOUMP_SOUND } from "@rahoot/web/utils/constants"
|
||||
import clsx from "clsx"
|
||||
import { useState } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
data: CommonStatusDataMap["SHOW_START"]
|
||||
}
|
||||
|
||||
const Start = ({ data: { time, subject } }: Props) => {
|
||||
const [showTitle, setShowTitle] = useState(true)
|
||||
const [cooldown, setCooldown] = useState(time)
|
||||
|
||||
const [sfxBoump] = useSound(SFX_BOUMP_SOUND, {
|
||||
volume: 0.2,
|
||||
})
|
||||
|
||||
useEvent("game:startCooldown", () => {
|
||||
sfxBoump()
|
||||
setShowTitle(false)
|
||||
})
|
||||
|
||||
useEvent("game:cooldown", (sec) => {
|
||||
sfxBoump()
|
||||
setCooldown(sec)
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-center">
|
||||
{showTitle ? (
|
||||
<h2 className="anim-show text-center text-3xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{subject}
|
||||
</h2>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
`anim-show bg-primary aspect-square h-32 transition-all md:h-60`,
|
||||
)}
|
||||
style={{
|
||||
transform: `rotate(${45 * (time - cooldown)}deg)`,
|
||||
}}
|
||||
></div>
|
||||
<span className="absolute text-6xl font-bold text-white drop-shadow-md md:text-8xl">
|
||||
{cooldown}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Start
|
||||
@@ -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
|
||||