commit 497dd2ea4cd473eff165ed3d01dc47e47e16a09a Author: RandyJC Date: Tue Dec 9 08:55:01 2025 +0100 Initial clean state diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca62534 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.pnpm-store +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37b9549 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/icon.svg b/.github/icon.svg new file mode 100644 index 0000000..054b45c --- /dev/null +++ b/.github/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..4e2668b --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/preview1.jpg b/.github/preview1.jpg new file mode 100644 index 0000000..af217d6 Binary files /dev/null and b/.github/preview1.jpg differ diff --git a/.github/preview2.jpg b/.github/preview2.jpg new file mode 100644 index 0000000..b62760f Binary files /dev/null and b/.github/preview2.jpg differ diff --git a/.github/preview3.jpg b/.github/preview3.jpg new file mode 100644 index 0000000..9c2f307 Binary files /dev/null and b/.github/preview3.jpg differ diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..e8d68fa --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed1665d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +.env +.secrets +release.json \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..efb5d6f --- /dev/null +++ b/.vscode/extensions.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" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..63d40be --- /dev/null +++ b/.vscode/settings.json @@ -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" + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02bec79 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f4b878 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..312f12f --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +

+ +
+

+ Visitor Badge + Docker Pulls +
+

+ +## 🧩 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) + +

+ Login + Manager Dashboard + Question Screen +

+ +## ⚙️ 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": "" }`. 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). diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..8db2259 --- /dev/null +++ b/compose.yml @@ -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 diff --git a/config/game.json b/config/game.json new file mode 100644 index 0000000..74e47a5 --- /dev/null +++ b/config/game.json @@ -0,0 +1,3 @@ +{ + "managerPassword": "PASSWORD" +} diff --git a/config/quizz/example.json b/config/quizz/example.json new file mode 100644 index 0000000..9ab200a --- /dev/null +++ b/config/quizz/example.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e173a4a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/common/.gitignore b/packages/common/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/packages/common/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/packages/common/eslint.config.mjs b/packages/common/eslint.config.mjs new file mode 100644 index 0000000..648d4db --- /dev/null +++ b/packages/common/eslint.config.mjs @@ -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"], + }, + }, +]) diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 0000000..ff3a73e --- /dev/null +++ b/packages/common/package.json @@ -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" + } +} diff --git a/packages/common/src/types/game/index.ts b/packages/common/src/types/game/index.ts new file mode 100644 index 0000000..7966ab1 --- /dev/null +++ b/packages/common/src/types/game/index.ts @@ -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 +} diff --git a/packages/common/src/types/game/socket.ts b/packages/common/src/types/game/socket.ts new file mode 100644 index 0000000..e194d21 --- /dev/null +++ b/packages/common/src/types/game/socket.ts @@ -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 +export type Socket = SocketIO + +export type Message = { + gameId?: string + status: K + data: StatusDataMap[K] +} + +export type MessageWithoutStatus = { + 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 +} diff --git a/packages/common/src/types/game/status.ts b/packages/common/src/types/game/status.ts new file mode 100644 index 0000000..39ecfad --- /dev/null +++ b/packages/common/src/types/game/status.ts @@ -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 + 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 diff --git a/packages/common/src/validators/auth.ts b/packages/common/src/validators/auth.ts new file mode 100644 index 0000000..28c1136 --- /dev/null +++ b/packages/common/src/validators/auth.ts @@ -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") diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 0000000..42ed9cb --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +} diff --git a/packages/socket/.gitignore b/packages/socket/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/packages/socket/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/packages/socket/esbuild.config.js b/packages/socket/esbuild.config.js new file mode 100644 index 0000000..4b258ab --- /dev/null +++ b/packages/socket/esbuild.config.js @@ -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) diff --git a/packages/socket/eslint.config.mjs b/packages/socket/eslint.config.mjs new file mode 100644 index 0000000..297a7d1 --- /dev/null +++ b/packages/socket/eslint.config.mjs @@ -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"], + }, + }, +]) diff --git a/packages/socket/package.json b/packages/socket/package.json new file mode 100644 index 0000000..6e13446 --- /dev/null +++ b/packages/socket/package.json @@ -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" + } +} diff --git a/packages/socket/src/env.ts b/packages/socket/src/env.ts new file mode 100644 index 0000000..3be0d1d --- /dev/null +++ b/packages/socket/src/env.ts @@ -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 diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts new file mode 100644 index 0000000..8499a1d --- /dev/null +++ b/packages/socket/src/index.ts @@ -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) +}) diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts new file mode 100644 index 0000000..af66a88 --- /dev/null +++ b/packages/socket/src/services/config.ts @@ -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 + ) { + 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 diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts new file mode 100644 index 0000000..12174a0 --- /dev/null +++ b/packages/socket/src/services/game.ts @@ -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 = + 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(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( + 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 { + if (this.cooldown.active) { + return Promise.resolve() + } + + this.cooldown.active = true + this.cooldown.paused = false + this.cooldown.remaining = seconds + + return new Promise((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, { 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 diff --git a/packages/socket/src/services/persistence.ts b/packages/socket/src/services/persistence.ts new file mode 100644 index 0000000..e7491de --- /dev/null +++ b/packages/socket/src/services/persistence.ts @@ -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 + +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 => { + 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}`) +} diff --git a/packages/socket/src/services/registry.ts b/packages/socket/src/services/registry.ts new file mode 100644 index 0000000..5143f55 --- /dev/null +++ b/packages/socket/src/services/registry.ts @@ -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 | 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 diff --git a/packages/socket/src/utils/game.ts b/packages/socket/src/utils/game.ts new file mode 100644 index 0000000..f6923d6 --- /dev/null +++ b/packages/socket/src/utils/game.ts @@ -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 +} diff --git a/packages/socket/src/utils/sleep.ts b/packages/socket/src/utils/sleep.ts new file mode 100644 index 0000000..f6116e2 --- /dev/null +++ b/packages/socket/src/utils/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (sec: number) => + new Promise((r) => void setTimeout(r, sec * 1000)) + +export default sleep diff --git a/packages/socket/tsconfig.json b/packages/socket/tsconfig.json new file mode 100644 index 0000000..43390f3 --- /dev/null +++ b/packages/socket/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "types": ["node"], + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +} diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 0000000..0c82063 --- /dev/null +++ b/packages/web/.gitignore @@ -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 diff --git a/packages/web/.prettierrc.json b/packages/web/.prettierrc.json new file mode 100644 index 0000000..8152947 --- /dev/null +++ b/packages/web/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "tabWidth": 2, + "singleQuote": false, + "printWidth": 80, + "trailingComma": "all", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/packages/web/eslint.config.mjs b/packages/web/eslint.config.mjs new file mode 100644 index 0000000..6b41f73 --- /dev/null +++ b/packages/web/eslint.config.mjs @@ -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", + }, + }, +]) diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs new file mode 100644 index 0000000..92337ee --- /dev/null +++ b/packages/web/next.config.mjs @@ -0,0 +1,10 @@ +const nextConfig = { + output: "standalone", + productionBrowserSourceMaps: false, + transpilePackages: ["packages/*", "@t3-oss/env-nextjs"], + eslint: { + ignoreDuringBuilds: true, + }, +} + +export default nextConfig diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..882ff70 --- /dev/null +++ b/packages/web/package.json @@ -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" + } +} diff --git a/packages/web/postcss.config.mjs b/packages/web/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/packages/web/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/packages/web/public/icon.svg b/packages/web/public/icon.svg new file mode 100644 index 0000000..054b45c --- /dev/null +++ b/packages/web/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/public/sounds/answersMusic.mp3 b/packages/web/public/sounds/answersMusic.mp3 new file mode 100644 index 0000000..c182028 Binary files /dev/null and b/packages/web/public/sounds/answersMusic.mp3 differ diff --git a/packages/web/public/sounds/answersSound.mp3 b/packages/web/public/sounds/answersSound.mp3 new file mode 100644 index 0000000..aa01044 Binary files /dev/null and b/packages/web/public/sounds/answersSound.mp3 differ diff --git a/packages/web/public/sounds/boump.mp3 b/packages/web/public/sounds/boump.mp3 new file mode 100644 index 0000000..42862b8 Binary files /dev/null and b/packages/web/public/sounds/boump.mp3 differ diff --git a/packages/web/public/sounds/first.mp3 b/packages/web/public/sounds/first.mp3 new file mode 100644 index 0000000..0fcf5e2 Binary files /dev/null and b/packages/web/public/sounds/first.mp3 differ diff --git a/packages/web/public/sounds/results.mp3 b/packages/web/public/sounds/results.mp3 new file mode 100644 index 0000000..172af89 Binary files /dev/null and b/packages/web/public/sounds/results.mp3 differ diff --git a/packages/web/public/sounds/second.mp3 b/packages/web/public/sounds/second.mp3 new file mode 100644 index 0000000..59ef123 Binary files /dev/null and b/packages/web/public/sounds/second.mp3 differ diff --git a/packages/web/public/sounds/show.mp3 b/packages/web/public/sounds/show.mp3 new file mode 100644 index 0000000..d11b30d Binary files /dev/null and b/packages/web/public/sounds/show.mp3 differ diff --git a/packages/web/public/sounds/snearRoll.mp3 b/packages/web/public/sounds/snearRoll.mp3 new file mode 100644 index 0000000..0293b2d Binary files /dev/null and b/packages/web/public/sounds/snearRoll.mp3 differ diff --git a/packages/web/public/sounds/three.mp3 b/packages/web/public/sounds/three.mp3 new file mode 100644 index 0000000..23c47a3 Binary files /dev/null and b/packages/web/public/sounds/three.mp3 differ diff --git a/packages/web/src/app/(auth)/layout.tsx b/packages/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..071be10 --- /dev/null +++ b/packages/web/src/app/(auth)/layout.tsx @@ -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 ( +
+
+
+
+
+ + logo + +

+ Loading... +

+
+ ) + } + + return ( +
+
+
+
+
+ + logo + {children} +
+ ) +} + +export default AuthLayout diff --git a/packages/web/src/app/(auth)/manager/page.tsx b/packages/web/src/app/(auth)/manager/page.tsx new file mode 100644 index 0000000..7394158 --- /dev/null +++ b/packages/web/src/app/(auth)/manager/page.tsx @@ -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([]) + 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 + } + + if (showEditor) { + return ( + setShowEditor(false)} + onListUpdate={setQuizzList} + /> + ) + } + + if (showMedia) { + return ( +
+
+ +
+ +
+ ) + } + + return ( + setShowEditor(true)} + onMedia={() => setShowMedia(true)} + /> + ) +} + +export default Manager diff --git a/packages/web/src/app/(auth)/page.tsx b/packages/web/src/app/(auth)/page.tsx new file mode 100644 index 0000000..b1873f9 --- /dev/null +++ b/packages/web/src/app/(auth)/page.tsx @@ -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 + } + + return +} + +export default Home diff --git a/packages/web/src/app/api/media/[file]/route.ts b/packages/web/src/app/api/media/[file]/route.ts new file mode 100644 index 0000000..4d3dabd --- /dev/null +++ b/packages/web/src/app/api/media/[file]/route.ts @@ -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 }) + } +} diff --git a/packages/web/src/app/api/media/route.ts b/packages/web/src/app/api/media/route.ts new file mode 100644 index 0000000..8759f5a --- /dev/null +++ b/packages/web/src/app/api/media/route.ts @@ -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 }) + } +} diff --git a/packages/web/src/app/game/[gameId]/page.tsx b/packages/web/src/app/game/[gameId]/page.tsx new file mode 100644 index 0000000..81b1549 --- /dev/null +++ b/packages/web/src/app/game/[gameId]/page.tsx @@ -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 = + + break + + case STATUS.SHOW_START: + component = + + break + + case STATUS.SHOW_PREPARED: + component = + + break + + case STATUS.SHOW_QUESTION: + component = + + break + + case STATUS.SHOW_RESULT: + component = + + break + + case STATUS.SELECT_ANSWER: + component = + + break + } + + return {component} +} + +export default Game diff --git a/packages/web/src/app/game/layout.tsx b/packages/web/src/app/game/layout.tsx new file mode 100644 index 0000000..7d4854a --- /dev/null +++ b/packages/web/src/app/game/layout.tsx @@ -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 diff --git a/packages/web/src/app/game/manager/[gameId]/page.tsx b/packages/web/src/app/game/manager/[gameId]/page.tsx new file mode 100644 index 0000000..ec93f46 --- /dev/null +++ b/packages/web/src/app/game/manager/[gameId]/page.tsx @@ -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 = + + break + + case STATUS.SHOW_START: + component = + + break + + case STATUS.SHOW_PREPARED: + component = + + break + + case STATUS.SHOW_QUESTION: + component = + + break + + case STATUS.SELECT_ANSWER: + component = + + break + + case STATUS.SHOW_RESPONSES: + component = + + break + + case STATUS.SHOW_LEADERBOARD: + component = + + break + + case STATUS.FINISHED: + component = + + break + } + + return ( + + {component} + + ) +} + +export default ManagerGame diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css new file mode 100644 index 0000000..ede069a --- /dev/null +++ b/packages/web/src/app/globals.css @@ -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); + } +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx new file mode 100644 index 0000000..e61d32c --- /dev/null +++ b/packages/web/src/app/layout.tsx @@ -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) => ( + + + +
{children}
+ +
+ + +) + +export default RootLayout diff --git a/packages/web/src/app/media/[file]/route.ts b/packages/web/src/app/media/[file]/route.ts new file mode 100644 index 0000000..2d6a579 --- /dev/null +++ b/packages/web/src/app/media/[file]/route.ts @@ -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 }) + } +} diff --git a/packages/web/src/app/socket/route.ts b/packages/web/src/app/socket/route.ts new file mode 100644 index 0000000..a98586b --- /dev/null +++ b/packages/web/src/app/socket/route.ts @@ -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" diff --git a/packages/web/src/assets/background.webp b/packages/web/src/assets/background.webp new file mode 100644 index 0000000..b82b4e7 Binary files /dev/null and b/packages/web/src/assets/background.webp differ diff --git a/packages/web/src/assets/loader.svg b/packages/web/src/assets/loader.svg new file mode 100644 index 0000000..9a69d11 --- /dev/null +++ b/packages/web/src/assets/loader.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/web/src/assets/logo.svg b/packages/web/src/assets/logo.svg new file mode 100644 index 0000000..f77d39f --- /dev/null +++ b/packages/web/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/src/components/AnswerButton.tsx b/packages/web/src/components/AnswerButton.tsx new file mode 100644 index 0000000..7b757ab --- /dev/null +++ b/packages/web/src/components/AnswerButton.tsx @@ -0,0 +1,27 @@ +import clsx from "clsx" +import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react" + +type Props = PropsWithChildren & + ButtonHTMLAttributes & { + icon: ElementType + } + +const AnswerButton = ({ + className, + icon: Icon, + children, + ...otherProps +}: Props) => ( + +) + +export default AnswerButton diff --git a/packages/web/src/components/Button.tsx b/packages/web/src/components/Button.tsx new file mode 100644 index 0000000..b6c5b56 --- /dev/null +++ b/packages/web/src/components/Button.tsx @@ -0,0 +1,18 @@ +import clsx from "clsx" +import { ButtonHTMLAttributes, PropsWithChildren } from "react" + +type Props = ButtonHTMLAttributes & PropsWithChildren + +const Button = ({ children, className, ...otherProps }: Props) => ( + +) + +export default Button diff --git a/packages/web/src/components/Form.tsx b/packages/web/src/components/Form.tsx new file mode 100644 index 0000000..7df80d5 --- /dev/null +++ b/packages/web/src/components/Form.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from "react" + +const Form = ({ children }: PropsWithChildren) => ( +
+ {children} +
+) + +export default Form diff --git a/packages/web/src/components/Input.tsx b/packages/web/src/components/Input.tsx new file mode 100644 index 0000000..0246d9a --- /dev/null +++ b/packages/web/src/components/Input.tsx @@ -0,0 +1,17 @@ +import clsx from "clsx" +import React from "react" + +type Props = React.InputHTMLAttributes + +const Input = ({ className, type = "text", ...otherProps }: Props) => ( + +) + +export default Input diff --git a/packages/web/src/components/Loader.tsx b/packages/web/src/components/Loader.tsx new file mode 100644 index 0000000..a760dec --- /dev/null +++ b/packages/web/src/components/Loader.tsx @@ -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) => ( + loader +) + +export default Loader diff --git a/packages/web/src/components/Toaster.tsx b/packages/web/src/components/Toaster.tsx new file mode 100644 index 0000000..351fdb7 --- /dev/null +++ b/packages/web/src/components/Toaster.tsx @@ -0,0 +1,27 @@ +"use client" + +import { ToastBar, Toaster as ToasterRaw } from "react-hot-toast" + +const Toaster = () => ( + + {(t) => ( + + {({ icon, message }) => ( + <> + {icon} + {message} + + )} + + )} + +) + +export default Toaster diff --git a/packages/web/src/components/game/GameWrapper.tsx b/packages/web/src/components/game/GameWrapper.tsx new file mode 100644 index 0000000..50ea048 --- /dev/null +++ b/packages/web/src/components/game/GameWrapper.tsx @@ -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 ( +
+
+ background +
+ + {!isConnected && !statusName ? ( +
+ +

Connecting...

+
+ ) : ( + <> +
+ {questionStates && ( +
+ {`${questionStates.current} / ${questionStates.total}`} +
+ )} + + {manager && next && ( + + )} + + {manager && showPause && ( + + )} + + {manager && onEnd && ( + + )} +
+ + {manager && players && players.length > 0 && ( +
+
+ Players ({players.length}) +
+
+ {players.map((p) => ( + + {p.username || p.id} {p.connected ? "" : "(disc.)"} + + ))} +
+
+ )} + + {children} + + {!manager && ( +
+

{player?.username}

+
+ {player?.points} +
+
+ )} + + )} +
+ ) +} + +export default GameWrapper diff --git a/packages/web/src/components/game/QuestionMedia.tsx b/packages/web/src/components/game/QuestionMedia.tsx new file mode 100644 index 0000000..e2b98a6 --- /dev/null +++ b/packages/web/src/components/game/QuestionMedia.tsx @@ -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 ( + <> +
+ {alt} setZoomed(true)} + /> +
+ {zoomed && ( +
setZoomed(false)} + > + {alt} +
+ )} + + ) + + case "audio": + return ( +
+
+ ) + + case "video": + return ( +
+
+ ) + + default: + return null + } +} + +export default QuestionMedia diff --git a/packages/web/src/components/game/create/ManagerPassword.tsx b/packages/web/src/components/game/create/ManagerPassword.tsx new file mode 100644 index 0000000..3ac5b7f --- /dev/null +++ b/packages/web/src/components/game/create/ManagerPassword.tsx @@ -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 ( +
+ setPassword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Manager password" + /> + +
+ ) +} + +export default ManagerPassword diff --git a/packages/web/src/components/game/create/MediaLibrary.tsx b/packages/web/src/components/game/create/MediaLibrary.tsx new file mode 100644 index 0000000..a79b4b2 --- /dev/null +++ b/packages/web/src/components/game/create/MediaLibrary.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [deleting, setDeleting] = useState>({}) + + 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 ( +
+
+
+

Media library

+

+ Uploaded files with their usage. Delete is enabled only when unused. +

+
+ +
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + {items.length === 0 && !loading && ( + + + + )} + +
FileTypeSizeUsed byActions
+ + {item.fileName} + + {item.type}{formatBytes(item.size)} + {item.usedBy.length === 0 ? ( + Unused + ) : ( +
+ {item.usedBy.map((u, idx) => ( +
+ {u.subject || u.quizzId} + {` – Q${u.questionIndex + 1}: ${u.question}`} +
+ ))} +
+ )} +
+ +
+ No media uploaded yet. +
+
+
+ ) +} + +export default MediaLibrary diff --git a/packages/web/src/components/game/create/QuizEditor.tsx b/packages/web/src/components/game/create/QuizEditor.tsx new file mode 100644 index 0000000..99f0bd6 --- /dev/null +++ b/packages/web/src/components/game/create/QuizEditor.tsx @@ -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 = { + 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(null) + const [draft, setDraft] = useState(null) + const [saving, setSaving] = useState(false) + const [loading, setLoading] = useState(false) + const [mediaLibrary, setMediaLibrary] = useState([]) + const [uploading, setUploading] = useState>({}) + const [deleting, setDeleting] = useState>({}) + const [refreshingLibrary, setRefreshingLibrary] = useState(false) + const [probing, setProbing] = useState>({}) + + 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, + ) => { + 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((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 ( +
+
+
+ + + {selectedId && ( + + )} +
+ + +
+ +
+
+ + Existing quizzes: + + {quizzList.map((quizz) => ( + + ))} +
+
+ + {!draft && ( +
+ {loading ? "Loading quiz..." : "Select a quiz to edit or create a new one."} +
+ )} + + {draft && ( +
+
+
+ {selectedLabel} +
+ +
+ + {draft.questions.map((question, qIndex) => { + const libraryEntry = getLibraryEntry(question.media) + const mediaFileName = getMediaFileName(question.media) + const isUploading = uploading[qIndex] + const isDeleting = deleting[qIndex] + + return ( +
+
+
+ Question {qIndex + 1} +
+
+ +
+
+ +
+ + +
+ + +
+
+ +
+ + +
+
+ Media upload + + {isUploading + ? "Uploading..." + : probing[qIndex] + ? "Probing..." + : refreshingLibrary + ? "Refreshing..." + : mediaFileName + ? "Stored" + : "Not saved"} + +
+ { + const file = e.target.files?.[0] + if (file) { + handleMediaUpload(qIndex, file) + e.target.value = "" + } + }} + /> +

+ Files are stored locally and served from /media. Pick a type first. +

+ + {question.media && ( +
+
+ + {mediaFileName || question.media.url || "No file yet"} + + {libraryEntry && ( + + {formatBytes(libraryEntry.size)} + + )} +
+
+ {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"} +
+
+ )} + + + + {question.media?.type !== "image" && question.media?.url && ( +
+ + + Probes audio/video duration and bumps cooldown/answer time if needed. + +
+ )} + +
+ + +
+
+
+ +
+
+ Answers + +
+ +
+ {question.answers.map((answer, aIndex) => ( +
+ + updateQuestion(qIndex, { solution: aIndex }) + } + /> + + updateAnswer(qIndex, aIndex, e.target.value) + } + placeholder={`Answer ${aIndex + 1}`} + /> + +
+ ))} +
+
+
+ ) + })} + +
+ +
+
+ )} +
+ ) +} + +export default QuizEditor diff --git a/packages/web/src/components/game/create/SelectQuizz.tsx b/packages/web/src/components/game/create/SelectQuizz.tsx new file mode 100644 index 0000000..c6cfb09 --- /dev/null +++ b/packages/web/src/components/game/create/SelectQuizz.tsx @@ -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(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 ( +
+
+

Select a quizz

+
+ {onMedia && ( + + )} + {onManage && ( + + )} +
+
+
+
+ {quizzList.map((quizz) => ( + + ))} +
+
+ +
+ ) +} + +export default SelectQuizz diff --git a/packages/web/src/components/game/join/Room.tsx b/packages/web/src/components/game/join/Room.tsx new file mode 100644 index 0000000..f9adeb4 --- /dev/null +++ b/packages/web/src/components/game/join/Room.tsx @@ -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 ( +
+ setInvitation(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="PIN Code here" + /> + +
+ ) +} + +export default Room diff --git a/packages/web/src/components/game/join/Username.tsx b/packages/web/src/components/game/join/Username.tsx new file mode 100644 index 0000000..9387e63 --- /dev/null +++ b/packages/web/src/components/game/join/Username.tsx @@ -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 ( +
+ setUsername(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Username here" + /> + +
+ ) +} + +export default Username diff --git a/packages/web/src/components/game/states/Answers.tsx b/packages/web/src/components/game/states/Answers.tsx new file mode 100644 index 0000000..c84c8ef --- /dev/null +++ b/packages/web/src/components/game/states/Answers.tsx @@ -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 ( +
+
+

+ {question} +

+ + setIsMediaPlaying(playing)} + /> +
+ +
+
+
+ Time + {cooldown} + {paused && ( + + Paused + + )} +
+
+ Answers + + {totalAnswer}/{totalPlayer} + +
+
+ +
+ {answers.map((answer, key) => ( + + {answer} + + ))} +
+
+
+ ) +} + +export default Answers diff --git a/packages/web/src/components/game/states/Leaderboard.tsx b/packages/web/src/components/game/states/Leaderboard.tsx new file mode 100644 index 0000000..364a1fa --- /dev/null +++ b/packages/web/src/components/game/states/Leaderboard.tsx @@ -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 {displayValue} +} + +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 ( +
+

+ Leaderboard +

+
+ + {displayedLeaderboard.map(({ id, username, points }) => ( + + {username} + {isAnimating ? ( + u.id === id)?.points || 0} + to={leaderboard.find((u) => u.id === id)?.points || 0} + /> + ) : ( + {points} + )} + + ))} + +
+
+ ) +} + +export default Leaderboard diff --git a/packages/web/src/components/game/states/Podium.tsx b/packages/web/src/components/game/states/Podium.tsx new file mode 100644 index 0000000..88f9d6d --- /dev/null +++ b/packages/web/src/components/game/states/Podium.tsx @@ -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 && ( + + )} + + {apparition >= 3 && top.length >= 3 && ( +
+
+
+ )} +
+

+ {subject} +

+ +
+ {top[1] && ( +
= 2 }, + )} + > +

= 4, + }, + )} + > + {top[1].username} +

+
+

+ 2 +

+

+ {top[1].points} +

+
+
+ )} + +
= 3, + }, + { + "md:min-w-64": top.length < 2, + }, + )} + > +

= 4 }, + )} + > + {top[0].username} +

+
+

+ 1 +

+

+ {top[0].points} +

+
+
+ + {top[2] && ( +
= 1, + }, + )} + > +

= 4, + }, + )} + > + {top[2].username} +

+
+

+ 3 +

+ +

+ {top[2].points} +

+
+
+ )} +
+
+ + ) +} + +export default Podium diff --git a/packages/web/src/components/game/states/Prepared.tsx b/packages/web/src/components/game/states/Prepared.tsx new file mode 100644 index 0000000..9ceba61 --- /dev/null +++ b/packages/web/src/components/game/states/Prepared.tsx @@ -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) => ( +
+

+ Question #{questionNumber} +

+
+ {[...Array(totalAnswers)].map((_, key) => ( +
+ {createElement(ANSWERS_ICONS[key], { className: "h-10 md:h-14" })} +
+ ))} +
+
+) + +export default Prepared diff --git a/packages/web/src/components/game/states/Question.tsx b/packages/web/src/components/game/states/Question.tsx new file mode 100644 index 0000000..feb47ed --- /dev/null +++ b/packages/web/src/components/game/states/Question.tsx @@ -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 ( +
+
+

+ {question} +

+ + +
+
+
+
+ {paused && ( +
+ Paused +
+ )} +
+ ) +} + +export default Question diff --git a/packages/web/src/components/game/states/Responses.tsx b/packages/web/src/components/game/states/Responses.tsx new file mode 100644 index 0000000..9b843e3 --- /dev/null +++ b/packages/web/src/components/game/states/Responses.tsx @@ -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>({}) + 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 ( +
+
+

+ {question} +

+ + setIsMediaPlaying(playing)} + /> + +
+ {answers.map((_, key) => ( +
+ + {responses[key] || 0} + +
+ ))} +
+
+ +
+
+ {answers.map((answer, key) => ( + + {answer} + + ))} +
+
+
+ ) +} + +export default Responses diff --git a/packages/web/src/components/game/states/Result.tsx b/packages/web/src/components/game/states/Result.tsx new file mode 100644 index 0000000..d94d4c5 --- /dev/null +++ b/packages/web/src/components/game/states/Result.tsx @@ -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 ( +
+ {correct ? ( + + ) : ( + + )} +

+ {message} +

+

+ {`You are top ${rank}${aheadOfMe ? `, behind ${aheadOfMe}` : ""}`} +

+ {correct && ( + + +{points} + + )} +
+ ) +} + +export default Result diff --git a/packages/web/src/components/game/states/Room.tsx b/packages/web/src/components/game/states/Room.tsx new file mode 100644 index 0000000..cdd341a --- /dev/null +++ b/packages/web/src/components/game/states/Room.tsx @@ -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(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 ( +
+
+ {inviteCode} +
+ +

+ {text} +

+ +
+ + Players Joined: {totalPlayers} + +
+ +
+ {playerList.map((player) => ( +
+ + {player.username} + +
+ ))} +
+
+ ) +} + +export default Room diff --git a/packages/web/src/components/game/states/Start.tsx b/packages/web/src/components/game/states/Start.tsx new file mode 100644 index 0000000..528a097 --- /dev/null +++ b/packages/web/src/components/game/states/Start.tsx @@ -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 ( +
+ {showTitle ? ( +

+ {subject} +

+ ) : ( + <> +
+ + {cooldown} + + + )} +
+ ) +} + +export default Start diff --git a/packages/web/src/components/game/states/Wait.tsx b/packages/web/src/components/game/states/Wait.tsx new file mode 100644 index 0000000..88b728c --- /dev/null +++ b/packages/web/src/components/game/states/Wait.tsx @@ -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) => ( +
+ +

+ {text} +

+
+) + +export default Wait diff --git a/packages/web/src/components/icons/Circle.tsx b/packages/web/src/components/icons/Circle.tsx new file mode 100644 index 0000000..da8973b --- /dev/null +++ b/packages/web/src/components/icons/Circle.tsx @@ -0,0 +1,24 @@ +type Props = { + className?: string + fill?: string +} + +const Circle = ({ className, fill = "#FFF" }: Props) => ( + + + + + + + +) + +export default Circle diff --git a/packages/web/src/components/icons/CricleCheck.tsx b/packages/web/src/components/icons/CricleCheck.tsx new file mode 100644 index 0000000..beb030c --- /dev/null +++ b/packages/web/src/components/icons/CricleCheck.tsx @@ -0,0 +1,41 @@ +type Props = { + className?: string +} + +const CricleCheck = ({ className }: Props) => ( + + + + + + + + + + + +) + +export default CricleCheck diff --git a/packages/web/src/components/icons/CricleXmark.tsx b/packages/web/src/components/icons/CricleXmark.tsx new file mode 100644 index 0000000..8700820 --- /dev/null +++ b/packages/web/src/components/icons/CricleXmark.tsx @@ -0,0 +1,35 @@ +type Props = { + className?: string +} + +const CricleXmark = ({ className }: Props) => ( + + + + + + + + + + + +) + +export default CricleXmark diff --git a/packages/web/src/components/icons/Pentagon.tsx b/packages/web/src/components/icons/Pentagon.tsx new file mode 100644 index 0000000..061b9b7 --- /dev/null +++ b/packages/web/src/components/icons/Pentagon.tsx @@ -0,0 +1,46 @@ +type Props = { + className?: string + fill?: string + stroke?: string +} + +const Pentagon = ({ className, fill, stroke }: Props) => ( + + + + + + + {" "} + + + + + + + + {" "} + + + + +) + +export default Pentagon diff --git a/packages/web/src/components/icons/Rhombus.tsx b/packages/web/src/components/icons/Rhombus.tsx new file mode 100644 index 0000000..940866b --- /dev/null +++ b/packages/web/src/components/icons/Rhombus.tsx @@ -0,0 +1,24 @@ +type Props = { + className?: string + fill?: string +} + +const Rhombus = ({ className, fill = "#FFF" }: Props) => ( + + + + + + + + + +) + +export default Rhombus diff --git a/packages/web/src/components/icons/Square.tsx b/packages/web/src/components/icons/Square.tsx new file mode 100644 index 0000000..fb42303 --- /dev/null +++ b/packages/web/src/components/icons/Square.tsx @@ -0,0 +1,17 @@ +type Props = { + className?: string + fill?: string +} + +const Square = ({ className, fill = "#FFF" }: Props) => ( + + + +) + +export default Square diff --git a/packages/web/src/components/icons/Triangle.tsx b/packages/web/src/components/icons/Triangle.tsx new file mode 100644 index 0000000..877a23c --- /dev/null +++ b/packages/web/src/components/icons/Triangle.tsx @@ -0,0 +1,17 @@ +type Props = { + className?: string + fill?: string +} + +const Triangle = ({ className, fill = "#FFF" }: Props) => ( + + + +) + +export default Triangle diff --git a/packages/web/src/contexts/socketProvider.tsx b/packages/web/src/contexts/socketProvider.tsx new file mode 100644 index 0000000..cb7e958 --- /dev/null +++ b/packages/web/src/contexts/socketProvider.tsx @@ -0,0 +1,195 @@ +/* eslint-disable no-empty-function */ +"use client" + +import { + ClientToServerEvents, + ServerToClientEvents, +} from "@rahoot/common/types/game/socket" +import ky from "ky" +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" +import { io, Socket } from "socket.io-client" +import { v7 as uuid } from "uuid" + +type TypedSocket = Socket + +interface SocketContextValue { + socket: TypedSocket | null + isConnected: boolean + clientId: string + connect: () => void + disconnect: () => void + reconnect: () => void +} + +const SocketContext = createContext({ + socket: null, + isConnected: false, + clientId: "", + connect: () => {}, + disconnect: () => {}, + reconnect: () => {}, +}) + +const getSocketServer = async () => { + try { + const res = await ky.get("/socket").json<{ url: string }>() + if (res.url) return res.url + } catch (error) { + console.error("Failed to fetch socket url, using fallback", error) + } + + if (typeof window !== "undefined") { + const { protocol, hostname } = window.location + const isHttps = protocol === "https:" + const port = + window.location.port && window.location.port !== "3000" + ? window.location.port + : "3001" + const scheme = isHttps ? "https:" : "http:" + + return `${scheme}//${hostname}:${port}` + } + + return "http://localhost:3001" +} + +const getClientId = (): string => { + try { + const stored = localStorage.getItem("client_id") + + if (stored) { + return stored + } + + const newId = uuid() + localStorage.setItem("client_id", newId) + + return newId + } catch { + return uuid() + } +} + +export const SocketProvider = ({ children }: { children: React.ReactNode }) => { + const [socket, setSocket] = useState(null) + const [isConnected, setIsConnected] = useState(false) + const [clientId] = useState(() => getClientId()) + + useEffect(() => { + if (socket) { + return + } + + let s: TypedSocket | null = null + + const initSocket = async () => { + try { + const socketUrl = await getSocketServer() + + const isHttps = socketUrl.startsWith("https") + + s = io(socketUrl, { + transports: ["websocket", "polling"], + autoConnect: false, + withCredentials: false, + forceNew: true, + secure: isHttps, + auth: { + clientId, + }, + reconnection: true, + reconnectionAttempts: 5, + timeout: 12000, + }) + + setSocket(s) + + s.on("connect", () => { + setIsConnected(true) + }) + + s.on("disconnect", () => { + setIsConnected(false) + }) + + s.on("connect_error", (err) => { + console.error("Connection error:", err.message, { + url: socketUrl, + transport: s?.io?.opts?.transports, + }) + }) + } catch (error) { + console.error("Failed to initialize socket:", error) + } + } + + initSocket() + + // eslint-disable-next-line consistent-return + return () => { + s?.disconnect() + } + }, [clientId]) + + const connect = useCallback(() => { + if (socket && !socket.connected) { + socket.connect() + } + }, [socket]) + + const disconnect = useCallback(() => { + if (socket && socket.connected) { + socket.disconnect() + } + }, [socket]) + + const reconnect = useCallback(() => { + if (socket) { + socket.disconnect() + socket.connect() + } + }, [socket]) + + return ( + + {children} + + ) +} + +export const useSocket = () => useContext(SocketContext) + +export const useEvent = ( + event: E, + callback: ServerToClientEvents[E], +) => { + const { socket } = useSocket() + + useEffect(() => { + if (!socket) { + return + } + + socket.on(event, callback as any) + + // eslint-disable-next-line consistent-return + return () => { + socket.off(event, callback as any) + } + }, [socket, event, callback]) +} diff --git a/packages/web/src/env.ts b/packages/web/src/env.ts new file mode 100644 index 0000000..d5337cf --- /dev/null +++ b/packages/web/src/env.ts @@ -0,0 +1,14 @@ +import { createEnv } from "@t3-oss/env-nextjs" +import { z } from "zod" + +const env = createEnv({ + server: { + SOCKET_URL: z.string().default("http://localhost:3001"), + }, + + runtimeEnv: { + SOCKET_URL: process.env.SOCKET_URL, + }, +}) + +export default env diff --git a/packages/web/src/hooks/useScreenSize.ts b/packages/web/src/hooks/useScreenSize.ts new file mode 100644 index 0000000..b031b3f --- /dev/null +++ b/packages/web/src/hooks/useScreenSize.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react" + +const useScreenSize = () => { + const [screenSize, setScreenSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }) + + useEffect(() => { + const handleResize = () => { + setScreenSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + } + + window.addEventListener("resize", handleResize) + + return () => { + window.removeEventListener("resize", handleResize) + } + }, []) + + return screenSize +} + +export default useScreenSize diff --git a/packages/web/src/server/media.ts b/packages/web/src/server/media.ts new file mode 100644 index 0000000..ceece4b --- /dev/null +++ b/packages/web/src/server/media.ts @@ -0,0 +1,257 @@ +import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game" +import Config from "@rahoot/socket/services/config" +import fs from "fs" +import { promises as fsp } from "fs" +import path from "path" + +const toBytes = (valueMb: number) => valueMb * 1024 * 1024 + +const envMaxMb = Number(process.env.MEDIA_MAX_UPLOAD_MB || process.env.MAX_UPLOAD_MB || 50) +const MAX_UPLOAD_SIZE = Number.isFinite(envMaxMb) && envMaxMb > 0 ? toBytes(envMaxMb) : toBytes(50) + +export type StoredMedia = { + fileName: string + url: string + size: number + mime: string + type: QuestionMedia["type"] + usedBy: { + quizzId: string + subject: string + questionIndex: number + question: string + }[] +} + +const ensureMediaFolder = () => { + Config.ensureBaseFolders() + const folder = Config.getMediaPath() + + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder, { recursive: true }) + } + + return folder +} + +const inferMimeFromName = (fileName: string) => { + const ext = path.extname(fileName).toLowerCase() + + const map: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".oga": "audio/ogg", + ".flac": "audio/flac", + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".ogv": "video/ogg", + ".mkv": "video/x-matroska", + } + + return map[ext] || "application/octet-stream" +} + +const inferMediaType = (mime: string): QuestionMedia["type"] | null => { + if (mime.startsWith("image/")) return "image" + if (mime.startsWith("audio/")) return "audio" + if (mime.startsWith("video/")) return "video" + return null +} + +const sanitizeFileName = (name: string) => { + const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_") + return safeName || `media-${Date.now()}` +} + +const resolveStoredFileName = (fileName: string) => { + const safeName = path.basename(fileName) + + if (safeName !== fileName) { + throw new Error("Invalid file name") + } + + return safeName +} + +const usageIndex = (quizzList: QuizzWithId[]) => { + const usage = new Map() + + const recordUsage = ( + fileName: string | null, + quizz: QuizzWithId, + questionIndex: number, + questionTitle: string, + ) => { + if (!fileName) return + + try { + const safeName = resolveStoredFileName(fileName) + const entries = usage.get(safeName) || [] + entries.push({ + quizzId: quizz.id, + subject: quizz.subject, + questionIndex, + question: questionTitle, + }) + usage.set(safeName, entries) + } catch (error) { + console.warn("Skipped invalid media reference", { fileName, error }) + } + } + + quizzList.forEach((quizz) => { + quizz.questions.forEach((question, idx) => { + const mediaFile = (() => { + if (question.media?.fileName) return question.media.fileName + if (question.media?.url?.startsWith("/media/")) { + try { + return resolveStoredFileName( + decodeURIComponent(question.media.url.split("/").pop() || ""), + ) + } catch (error) { + console.warn("Skipped invalid media url reference", { + url: question.media.url, + error, + }) + return null + } + } + return null + })() + + const imageFile = (() => { + if (!question.image?.startsWith("/media/")) return null + try { + return resolveStoredFileName( + decodeURIComponent(question.image.split("/").pop() || ""), + ) + } catch (error) { + console.warn("Skipped invalid image url reference", { + url: question.image, + error, + }) + return null + } + })() + + recordUsage(mediaFile, quizz, idx, question.question) + recordUsage(imageFile, quizz, idx, question.question) + }) + }) + + return usage +} + +export const listStoredMedia = async (): Promise => { + const folder = ensureMediaFolder() + const files = await fsp.readdir(folder) + const quizz = Config.quizz() + const usage = usageIndex(quizz) + + const entries = await Promise.all( + files.map(async (fileName) => { + const stats = await fsp.stat(path.join(folder, fileName)) + const mime = inferMimeFromName(fileName) + const type = inferMediaType(mime) || "video" + + return { + fileName, + url: `/media/${encodeURIComponent(fileName)}`, + size: stats.size, + mime, + type, + usedBy: usage.get(fileName) || [], + } + }), + ) + + // Keep a stable order for repeatable responses + return entries.sort((a, b) => a.fileName.localeCompare(b.fileName)) +} + +export const storeMediaFile = async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + if (buffer.byteLength > MAX_UPLOAD_SIZE) { + throw new Error( + `File is too large. Max ${Math.round(MAX_UPLOAD_SIZE / 1024 / 1024)}MB.`, + ) + } + + const targetFolder = ensureMediaFolder() + const incomingMime = file.type || "application/octet-stream" + const mediaType = inferMediaType(incomingMime) + + if (!mediaType) { + throw new Error("Unsupported media type") + } + + const incomingName = file.name || `${mediaType}-upload` + const safeName = sanitizeFileName(incomingName) + const ext = path.extname(safeName) || `.${incomingMime.split("/")[1] || "bin"}` + const baseName = path.basename(safeName, ext) + + let finalName = `${baseName}${ext}` + let finalPath = path.join(targetFolder, finalName) + let counter = 1 + + while (fs.existsSync(finalPath)) { + finalName = `${baseName}-${counter}${ext}` + finalPath = path.join(targetFolder, finalName) + counter += 1 + } + + await fsp.writeFile(finalPath, buffer) + + const mime = incomingMime || inferMimeFromName(finalName) + + return { + fileName: finalName, + url: `/media/${encodeURIComponent(finalName)}`, + size: buffer.byteLength, + mime, + type: mediaType, + usedBy: [], + } +} + +export const deleteMediaFile = async (fileName: string) => { + const folder = ensureMediaFolder() + const safeName = resolveStoredFileName(fileName) + const filePath = path.join(folder, safeName) + + if (!fs.existsSync(filePath)) { + throw new Error("File not found") + } + + const usage = usageIndex(Config.quizz()) + const usedBy = usage.get(safeName) || [] + + if (usedBy.length > 0) { + const details = usedBy + .map( + (entry) => + `${entry.subject || entry.quizzId} (question ${entry.questionIndex + 1})`, + ) + .join(", ") + + throw new Error(`File is still used by: ${details}`) + } + + await fsp.unlink(filePath) +} + +export const mimeForStoredFile = (fileName: string) => inferMimeFromName(fileName) diff --git a/packages/web/src/stores/manager.tsx b/packages/web/src/stores/manager.tsx new file mode 100644 index 0000000..a1ebbaf --- /dev/null +++ b/packages/web/src/stores/manager.tsx @@ -0,0 +1,39 @@ +import { Player } from "@rahoot/common/types/game" +import { StatusDataMap } from "@rahoot/common/types/game/status" +import { createStatus, Status } from "@rahoot/web/utils/createStatus" +import { create } from "zustand" + +type ManagerStore = { + gameId: string | null + status: Status | null + players: Player[] + + setGameId: (_gameId: string | null) => void + setStatus: (_name: K, _data: T[K]) => void + resetStatus: () => void + setPlayers: (_players: Player[] | ((_prev: Player[]) => Player[])) => void + + reset: () => void +} + +const initialState = { + gameId: null, + status: null, + players: [], +} + +export const useManagerStore = create>((set) => ({ + ...initialState, + + setGameId: (gameId) => set({ gameId }), + + setStatus: (name, data) => set({ status: createStatus(name, data) }), + resetStatus: () => set({ status: null }), + + setPlayers: (players) => + set((state) => ({ + players: typeof players === "function" ? players(state.players) : players, + })), + + reset: () => set(initialState), +})) diff --git a/packages/web/src/stores/player.tsx b/packages/web/src/stores/player.tsx new file mode 100644 index 0000000..7173deb --- /dev/null +++ b/packages/web/src/stores/player.tsx @@ -0,0 +1,82 @@ +import { StatusDataMap } from "@rahoot/common/types/game/status" +import { createStatus, Status } from "@rahoot/web/utils/createStatus" +import { create } from "zustand" + +type PlayerState = { + username?: string + points?: number +} + +type PlayerStore = { + gameId: string | null + player: PlayerState | null + status: Status | null + + setGameId: (_gameId: string | null) => void + + setPlayer: (_state: PlayerState) => void + login: (_gameId: string) => void + join: (_username: string) => void + updatePoints: (_points: number) => void + + setStatus: (_name: K, _data: T[K]) => void + + reset: () => void +} + +const initialState = { + gameId: null, + player: null, + status: null, +} + +export const usePlayerStore = create>((set) => ({ + ...initialState, + + setGameId: (gameId) => set({ gameId }), + + setPlayer: (player: PlayerState) => { + try { + if (player.username) localStorage.setItem("last_username", player.username) + if (typeof player.points === "number") { + localStorage.setItem("last_points", String(player.points)) + } + } catch {} + set({ player }) + }, + login: (username) => + set((state) => { + try { + localStorage.setItem("last_username", username) + } catch {} + return { + player: { ...state.player, username }, + } + }), + + join: (gameId) => { + set((state) => ({ + gameId, + player: { ...state.player, points: 0 }, + })) + }, + + updatePoints: (points) => { + try { + localStorage.setItem("last_points", String(points)) + } catch {} + set((state) => ({ + player: { ...state.player, points }, + })) + }, + + setStatus: (name, data) => set({ status: createStatus(name, data) }), + + reset: () => { + try { + localStorage.removeItem("last_username") + localStorage.removeItem("last_points") + } catch {} + set(initialState) + }, +})) diff --git a/packages/web/src/stores/question.tsx b/packages/web/src/stores/question.tsx new file mode 100644 index 0000000..cc06fd8 --- /dev/null +++ b/packages/web/src/stores/question.tsx @@ -0,0 +1,12 @@ +import { GameUpdateQuestion } from "@rahoot/common/types/game" +import { create } from "zustand" + +type QuestionStore = { + questionStates: GameUpdateQuestion | null + setQuestionStates: (_state: GameUpdateQuestion | null) => void +} + +export const useQuestionStore = create((set) => ({ + questionStates: null, + setQuestionStates: (state) => set({ questionStates: state }), +})) diff --git a/packages/web/src/utils/constants.ts b/packages/web/src/utils/constants.ts new file mode 100644 index 0000000..6a9c996 --- /dev/null +++ b/packages/web/src/utils/constants.ts @@ -0,0 +1,76 @@ +import Answers from "@rahoot/web/components/game/states/Answers" +import Leaderboard from "@rahoot/web/components/game/states/Leaderboard" +import Podium from "@rahoot/web/components/game/states/Podium" +import Prepared from "@rahoot/web/components/game/states/Prepared" +import Question from "@rahoot/web/components/game/states/Question" +import Responses from "@rahoot/web/components/game/states/Responses" +import Result from "@rahoot/web/components/game/states/Result" +import Room from "@rahoot/web/components/game/states/Room" +import Start from "@rahoot/web/components/game/states/Start" +import Wait from "@rahoot/web/components/game/states/Wait" + +import { STATUS } from "@rahoot/common/types/game/status" +import Circle from "@rahoot/web/components/icons/Circle" +import Rhombus from "@rahoot/web/components/icons/Rhombus" +import Square from "@rahoot/web/components/icons/Square" +import Triangle from "@rahoot/web/components/icons/Triangle" + +export const ANSWERS_COLORS = [ + "bg-red-500", + "bg-blue-500", + "bg-yellow-500", + "bg-green-500", +] + +export const ANSWERS_ICONS = [Triangle, Rhombus, Circle, Square] + +export const GAME_STATES = { + status: { + name: STATUS.WAIT, + data: { text: "Waiting for the players" }, + }, + question: { + current: 1, + total: null, + }, +} + +export const GAME_STATE_COMPONENTS = { + [STATUS.SELECT_ANSWER]: Answers, + [STATUS.SHOW_QUESTION]: Question, + [STATUS.WAIT]: Wait, + [STATUS.SHOW_START]: Start, + [STATUS.SHOW_RESULT]: Result, + [STATUS.SHOW_PREPARED]: Prepared, +} + +export const GAME_STATE_COMPONENTS_MANAGER = { + ...GAME_STATE_COMPONENTS, + [STATUS.SHOW_ROOM]: Room, + [STATUS.SHOW_RESPONSES]: Responses, + [STATUS.SHOW_LEADERBOARD]: Leaderboard, + [STATUS.FINISHED]: Podium, +} + +export const SFX_ANSWERS_MUSIC = "/sounds/answersMusic.mp3" +export const SFX_ANSWERS_SOUND = "/sounds/answersSound.mp3" +export const SFX_RESULTS_SOUND = "/sounds/results.mp3" +export const SFX_SHOW_SOUND = "/sounds/show.mp3" +export const SFX_BOUMP_SOUND = "/sounds/boump.mp3" +export const SFX_PODIUM_THREE = "/sounds/three.mp3" +export const SFX_PODIUM_SECOND = "/sounds/second.mp3" +export const SFX_PODIUM_FIRST = "/sounds/first.mp3" +export const SFX_SNEAR_ROOL = "/sounds/snearRoll.mp3" + +export const MANAGER_SKIP_BTN = { + [STATUS.SHOW_ROOM]: "Start Game", + [STATUS.SHOW_START]: null, + [STATUS.SHOW_PREPARED]: null, + [STATUS.SHOW_QUESTION]: "Skip", + [STATUS.SELECT_ANSWER]: "Skip", + [STATUS.SHOW_RESULT]: null, + [STATUS.SHOW_RESPONSES]: "Next", + [STATUS.SHOW_LEADERBOARD]: "Next", + [STATUS.FINISHED]: null, + [STATUS.WAIT]: null, +} diff --git a/packages/web/src/utils/createStatus.ts b/packages/web/src/utils/createStatus.ts new file mode 100644 index 0000000..8c9dba4 --- /dev/null +++ b/packages/web/src/utils/createStatus.ts @@ -0,0 +1,8 @@ +export type Status = { + [K in keyof T]: { name: K; data: T[K] } +}[keyof T] + +export const createStatus = ( + name: K, + data: T[K], +): Status => ({ name, data }) diff --git a/packages/web/src/utils/score.ts b/packages/web/src/utils/score.ts new file mode 100644 index 0000000..ea1b1a1 --- /dev/null +++ b/packages/web/src/utils/score.ts @@ -0,0 +1,23 @@ +export const calculatePercentages = ( + objectResponses: Record, +): Record => { + const keys = Object.keys(objectResponses) + const values = Object.values(objectResponses) + + if (!values.length) { + return {} + } + + const totalSum = values.reduce( + (accumulator, currentValue) => accumulator + currentValue, + 0, + ) + + const result: Record = {} + + keys.forEach((key) => { + result[key] = `${((objectResponses[key] / totalSum) * 100).toFixed()}%` + }) + + return result +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..cbf9110 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + "dist/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..49f3421 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5078 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + dotenv-cli: + specifier: ^10.0.0 + version: 10.0.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/common: + dependencies: + socket.io: + specifier: ^4.8.1 + version: 4.8.1 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@eslint/js': + specifier: ^9.38.0 + version: 9.38.0 + '@types/node': + specifier: ^20.19.23 + version: 20.19.23 + eslint: + specifier: ^9.38.0 + version: 9.38.0(jiti@2.6.1) + globals: + specifier: ^16.4.0 + version: 16.4.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.2 + version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + + packages/socket: + dependencies: + '@rahoot/common': + specifier: workspace:* + version: link:../common + '@t3-oss/env-core': + specifier: ^0.13.8 + version: 0.13.8(typescript@5.9.3)(zod@4.1.12) + dayjs: + specifier: ^1.11.18 + version: 1.11.18 + redis: + specifier: ^4.6.13 + version: 4.7.1 + socket.io: + specifier: ^4.8.1 + version: 4.8.1 + uuid: + specifier: ^13.0.0 + version: 13.0.0 + zod: + specifier: ^4.1.12 + version: 4.1.12 + devDependencies: + '@eslint/js': + specifier: ^9.38.0 + version: 9.38.0 + '@types/node': + specifier: ^24.9.1 + version: 24.9.1 + esbuild: + specifier: ^0.25.11 + version: 0.25.11 + eslint: + specifier: ^9.38.0 + version: 9.38.0(jiti@2.6.1) + globals: + specifier: ^16.4.0 + version: 16.4.0 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript-eslint: + specifier: ^8.46.2 + version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + + packages/web: + dependencies: + '@rahoot/common': + specifier: workspace:* + version: link:../common + '@rahoot/socket': + specifier: workspace:* + version: link:../socket + '@t3-oss/env-nextjs': + specifier: ^0.13.8 + version: 0.13.8(typescript@5.9.3)(zod@4.1.12) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + ky: + specifier: ^1.13.0 + version: 1.13.0 + motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: + specifier: 15.5.4 + version: 15.5.4(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-confetti: + specifier: ^6.4.0 + version: 6.4.0(react@19.1.0) + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 + use-sound: + specifier: ^5.0.0 + version: 5.0.0(react@19.1.0) + uuid: + specifier: ^13.0.0 + version: 13.0.0 + yup: + specifier: ^1.7.1 + version: 1.7.1 + zod: + specifier: ^4.1.12 + version: 4.1.12 + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.2.2)(react@19.1.0) + devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.1 + version: 3.3.1 + '@tailwindcss/postcss': + specifier: ^4.1.16 + version: 4.1.16 + '@types/node': + specifier: ^20.19.23 + version: 20.19.23 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + eslint: + specifier: ^9.38.0 + version: 9.38.0(jiti@2.6.1) + eslint-config-next: + specifier: 15.5.4 + version: 15.5.4(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-react-hooks: + specifier: ^6.1.1 + version: 6.1.1(eslint@9.38.0(jiti@2.6.1)) + globals: + specifier: ^16.4.0 + version: 16.4.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.14 + version: 0.6.14(prettier@3.6.2) + tailwindcss: + specifier: ^4.1.16 + version: 4.1.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.2 + version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.6.0': + resolution: {integrity: sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==} + + '@emnapi/runtime@1.6.0': + resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.1': + resolution: {integrity: sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.16.0': + resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.38.0': + resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.0': + resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.4': + resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.4': + resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.3': + resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.3': + resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.3': + resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.3': + resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.3': + resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.3': + resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.3': + resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.3': + resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.4': + resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.4': + resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.4': + resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.4': + resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.4': + resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.4': + resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.4': + resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.4': + resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.4': + resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.4': + resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.4': + resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@15.5.4': + resolution: {integrity: sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==} + + '@next/eslint-plugin-next@15.5.4': + resolution: {integrity: sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==} + + '@next/swc-darwin-arm64@15.5.4': + resolution: {integrity: sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.4': + resolution: {integrity: sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.4': + resolution: {integrity: sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.4': + resolution: {integrity: sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.4': + resolution: {integrity: sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.4': + resolution: {integrity: sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.4': + resolution: {integrity: sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.4': + resolution: {integrity: sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@rushstack/eslint-patch@1.14.1': + resolution: {integrity: sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@t3-oss/env-core@0.13.8': + resolution: {integrity: sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.13.8': + resolution: {integrity: sha512-QmTLnsdQJ8BiQad2W2nvV6oUpH4oMZMqnFEjhVpzU0h3sI9hn8zb8crjWJ1Amq453mGZs6A4v4ihIeBFDOrLeQ==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@tailwindcss/node@4.1.16': + resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + + '@tailwindcss/oxide-android-arm64@4.1.16': + resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.16': + resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.16': + resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.16': + resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.23': + resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} + + '@types/node@24.9.1': + resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + + '@types/react-dom@19.2.2': + resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.2': + resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + + '@typescript-eslint/eslint-plugin@8.46.2': + resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.46.2 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.46.2': + resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.46.2': + resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.46.2': + resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.46.2': + resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.46.2': + resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.46.2': + resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.46.2': + resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.46.2': + resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.46.2': + resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.0: + resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + baseline-browser-mapping@2.8.20: + resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.27.0: + resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001751: + resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dotenv-cli@10.0.0: + resolution: {integrity: sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==} + hasBin: true + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.240: + resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + engine.io-client@6.6.3: + resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.4: + resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.1: + resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@15.5.4: + resolution: {integrity: sha512-BzgVVuT3kfJes8i2GHenC1SRJ+W3BTML11lAOYFOOPzrk2xp66jBOAGEFRw+3LkYCln5UzvFsLhojrshb5Zfaw==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-hooks@6.1.1: + resolution: {integrity: sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.38.0: + resolution: {integrity: sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + howler@2.2.4: + resolution: {integrity: sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + ky@1.13.0: + resolution: {integrity: sha512-JeNNGs44hVUp2XxO3FY9WV28ymG7LgO4wju4HL/dCq1A8eKDcFgVrdCn1ssn+3Q/5OQilv5aYsL0DMt5mmAV9w==} + engines: {node: '>=18'} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.24: + resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + next@15.5.4: + resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-releases@2.0.26: + resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.6.14: + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-confetti@6.4.0: + resolution: {integrity: sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==} + engines: {node: '>=16'} + peerDependencies: + react: ^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-hot-toast@2.6.0: + resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.4: + resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@4.1.16: + resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tween-functions@1.2.0: + resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.46.2: + resolution: {integrity: sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sound@5.0.0: + resolution: {integrity: sha512-MNHT3FFC5HxNCrgZtrnpIMJI2cw/0D2xismcrtyht8BTuF5FhFhb57xO/jlQr2xJaFrc/0btzRQvGyHQwB7PVA==} + peerDependencies: + react: '>=16.8' + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.27.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.6.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.6.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))': + dependencies: + eslint: 9.38.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.1': + dependencies: + '@eslint/core': 0.16.0 + + '@eslint/core@0.16.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.38.0': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.0': + dependencies: + '@eslint/core': 0.16.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.3 + optional: true + + '@img/sharp-darwin-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.3': + optional: true + + '@img/sharp-linux-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.3 + optional: true + + '@img/sharp-linux-arm@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.3 + optional: true + + '@img/sharp-linux-ppc64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.3 + optional: true + + '@img/sharp-linux-s390x@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.3 + optional: true + + '@img/sharp-linux-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + optional: true + + '@img/sharp-wasm32@0.34.4': + dependencies: + '@emnapi/runtime': 1.6.0 + optional: true + + '@img/sharp-win32-arm64@0.34.4': + optional: true + + '@img/sharp-win32-ia32@0.34.4': + optional: true + + '@img/sharp-win32-x64@0.34.4': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.6.0 + '@emnapi/runtime': 1.6.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@15.5.4': {} + + '@next/eslint-plugin-next@15.5.4': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@15.5.4': + optional: true + + '@next/swc-darwin-x64@15.5.4': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.4': + optional: true + + '@next/swc-linux-arm64-musl@15.5.4': + optional: true + + '@next/swc-linux-x64-gnu@15.5.4': + optional: true + + '@next/swc-linux-x64-musl@15.5.4': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.4': + optional: true + + '@next/swc-win32-x64-msvc@15.5.4': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@rtsao/scc@1.1.0': {} + + '@rushstack/eslint-patch@1.14.1': {} + + '@socket.io/component-emitter@3.1.2': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@t3-oss/env-core@0.13.8(typescript@5.9.3)(zod@4.1.12)': + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.12 + + '@t3-oss/env-nextjs@0.13.8(typescript@5.9.3)(zod@4.1.12)': + dependencies: + '@t3-oss/env-core': 0.13.8(typescript@5.9.3)(zod@4.1.12) + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.12 + + '@tailwindcss/node@4.1.16': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.16 + + '@tailwindcss/oxide-android-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide@4.1.16': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-x64': 4.1.16 + '@tailwindcss/oxide-freebsd-x64': 4.1.16 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-x64-musl': 4.1.16 + '@tailwindcss/oxide-wasm32-wasi': 4.1.16 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 + + '@tailwindcss/postcss@4.1.16': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.16 + '@tailwindcss/oxide': 4.1.16 + postcss: 8.5.6 + tailwindcss: 4.1.16 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.23 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.23': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.9.1': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.2(@types/react@19.2.2)': + dependencies: + '@types/react': 19.2.2 + + '@types/react@19.2.2': + dependencies: + csstype: 3.1.3 + + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 + eslint: 9.38.0(jiti@2.6.1) + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 + debug: 4.4.3 + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.46.2': + dependencies: + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 + + '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.38.0(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.46.2': {} + + '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.46.2': + dependencies: + '@typescript-eslint/types': 8.46.2 + eslint-visitor-keys: 4.2.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.0: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + base64id@2.0.0: {} + + baseline-browser-mapping@2.8.20: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.27.0: + dependencies: + baseline-browser-mapping: 2.8.20 + caniuse-lite: 1.0.30001751 + electron-to-chromium: 1.5.240 + node-releases: 2.0.26 + update-browserslist-db: 1.1.4(browserslist@4.27.0) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001751: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cluster-key-slot@1.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@0.7.2: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dayjs@1.11.18: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dotenv-cli@10.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.2.3 + dotenv-expand: 11.0.7 + minimist: 1.2.8 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.240: {} + + emoji-regex@9.2.2: {} + + engine.io-client@6.6.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.4: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 20.19.23 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@15.5.4(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 15.5.4 + '@rushstack/eslint-patch': 1.14.1 + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.38.0(jiti@2.6.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.38.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.38.0(jiti@2.6.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.38.0(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.38.0(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.38.0(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@2.6.1)): + dependencies: + eslint: 9.38.0(jiti@2.6.1) + + eslint-plugin-react-hooks@6.1.1(eslint@9.38.0(jiti@2.6.1)): + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + eslint: 9.38.0(jiti@2.6.1) + zod: 4.1.12 + zod-validation-error: 4.0.2(zod@4.1.12) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.38.0(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.38.0(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.38.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.1 + '@eslint/core': 0.16.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.38.0 + '@eslint/plugin-kit': 0.4.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + generic-pool@3.9.0: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + goober@2.1.18(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + howler@2.2.4: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.3 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + ky@1.13.0: {} + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + framer-motion: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + next@15.5.4(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 15.5.4 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001751 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.4 + '@next/swc-darwin-x64': 15.5.4 + '@next/swc-linux-arm64-gnu': 15.5.4 + '@next/swc-linux-arm64-musl': 15.5.4 + '@next/swc-linux-x64-gnu': 15.5.4 + '@next/swc-linux-x64-musl': 15.5.4 + '@next/swc-win32-arm64-msvc': 15.5.4 + '@next/swc-win32-x64-msvc': 15.5.4 + sharp: 0.34.4 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-releases@2.0.26: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.6.14(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-expr@2.0.6: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-confetti@6.4.0(react@19.1.0): + dependencies: + react: 19.1.0 + tween-functions: 1.2.0 + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-hot-toast@2.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + csstype: 3.1.3 + goober: 2.1.18(csstype@3.1.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + react-is@16.13.1: {} + + react@19.1.0: {} + + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.4: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.4 + '@img/sharp-darwin-x64': 0.34.4 + '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-linux-arm': 0.34.4 + '@img/sharp-linux-arm64': 0.34.4 + '@img/sharp-linux-ppc64': 0.34.4 + '@img/sharp-linux-s390x': 0.34.4 + '@img/sharp-linux-x64': 0.34.4 + '@img/sharp-linuxmusl-arm64': 0.34.4 + '@img/sharp-linuxmusl-x64': 0.34.4 + '@img/sharp-wasm32': 0.34.4 + '@img/sharp-win32-arm64': 0.34.4 + '@img/sharp-win32-ia32': 0.34.4 + '@img/sharp-win32-x64': 0.34.4 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.3 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.4 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + optionalDependencies: + '@babel/core': 7.28.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@4.1.16: {} + + tapable@2.3.0: {} + + tiny-case@1.0.3: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toposort@2.0.2: {} + + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.11 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + tween-functions@1.2.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@2.19.0: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.38.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + undici-types@7.16.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.1.4(browserslist@4.27.0): + dependencies: + browserslist: 4.27.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sound@5.0.0(react@19.1.0): + dependencies: + howler: 2.2.4 + react: 19.1.0 + + uuid@13.0.0: {} + + vary@1.1.2: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + ws@8.17.1: {} + + xmlhttprequest-ssl@2.1.2: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yocto-queue@0.1.0: {} + + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + + zod-validation-error@4.0.2(zod@4.1.12): + dependencies: + zod: 4.1.12 + + zod@3.25.76: {} + + zod@4.1.12: {} + + zustand@5.0.8(@types/react@19.2.2)(react@19.1.0): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..69d083f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "noEmit": true, + "noErrorTruncation": true, + "esModuleInterop": true, + "paths": { + "@rahoot/common/*": ["./packages/common/src/*"], + "@rahoot/socket/*": ["./packages/socket/src/*"], + "@rahoot/web/*": ["./packages/web/src/*"] + }, + "strict": true, + "strictNullChecks": true + } +}