diff --git a/.gitea/workflows/on-push-feature.yml b/.gitea/workflows/on-push-feature.yml new file mode 100644 index 0000000..42291b7 --- /dev/null +++ b/.gitea/workflows/on-push-feature.yml @@ -0,0 +1,46 @@ +name: Deploy Feature + +on: + push: + branches: + - feature-* + +jobs: + deploy-feature: + runs-on: pusher + container: + volumes: + - /repos:/repos + - /www/jeopardy-test:/www/jeopardy-test + steps: + - name: Install Node.js and npm + run: | + set -e + apk add --no-cache nodejs npm + node -v + npm -v + + - name: Build app (feature) + run: | + set -euo pipefail + BRANCH="${GITHUB_REF_NAME:-}" + if [ -z "$BRANCH" ] && [ -n "${GITHUB_REF:-}" ]; then + BRANCH="${GITHUB_REF#refs/heads/}" + fi + if [ -z "$BRANCH" ]; then + echo "Unable to determine branch name." + exit 1 + fi + cd /repos/music-jeopardy + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/$BRANCH" + npm ci --include=dev + npm run generate:data + npm run build + + - name: Deploy static files to test + run: | + set -euo pipefail + mkdir -p /www/jeopardy-test + find /www/jeopardy-test -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -r /repos/music-jeopardy/dist/. /www/jeopardy-test/ diff --git a/.gitea/workflows/on-push-master.yml b/.gitea/workflows/on-push-master.yml new file mode 100644 index 0000000..045b9ea --- /dev/null +++ b/.gitea/workflows/on-push-master.yml @@ -0,0 +1,49 @@ +name: Deploy Master + +on: + push: + branches: + - master + +jobs: + deploy-master: + runs-on: pusher + container: + volumes: + - /repos:/repos + - /www/jeopardy:/www/jeopardy + steps: + - name: Install Node.js and npm + run: | + set -e + apk add --no-cache nodejs npm + node -v + npm -v + + - name: Build app (master) + run: | + set -euo pipefail + cd /repos/music-jeopardy + git fetch origin master + git checkout -B master origin/master + npm ci --include=dev + npm run generate:data + npm run build + + - name: Deploy static files to production + run: | + set -euo pipefail + mkdir -p /www/jeopardy + find /www/jeopardy -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -r /repos/music-jeopardy/dist/. /www/jeopardy/ + + - name: Restart realtime service (if available) + run: | + set -e + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload + systemctl enable --now music-jeopardy-realtime.service + systemctl restart music-jeopardy-realtime.service + else + echo "systemctl not available in this runner container. Restart on host manually." + fi diff --git a/.gitea/workflows/on-push.yml b/.gitea/workflows/on-push.yml deleted file mode 100644 index fa450c3..0000000 --- a/.gitea/workflows/on-push.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: On Push Deploy - -on: - push: - -jobs: - deploy: - runs-on: pusher - container: - volumes: - - /repos:/repos - - /www/jeopardy:/www/jeopardy - steps: - - name: Install Node.js & npm - run: | - apk add --no-cache nodejs npm # if your job container is Alpine - node -v - npm -v - - name: Pull and pull - run: | - ls /www/jeopardy - - name: Install dependencies - run: | - cd /repos/music-jeopardy - git pull - npm install - - name: Build production bundle - run: | - cd /repos/music-jeopardy - npm run generate:data - npm run build - cp -r /repos/music-jeopardy/dist/* /www/jeopardy diff --git a/.gitignore b/.gitignore index c2658d7..04c01ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ \ No newline at end of file diff --git a/deploy/SETUP.md b/deploy/SETUP.md new file mode 100644 index 0000000..0d1c543 --- /dev/null +++ b/deploy/SETUP.md @@ -0,0 +1,25 @@ +# Production Setup + +## 1) Install systemd service + +```bash +sudo cp /repos/music-jeopardy/deploy/systemd/music-jeopardy-realtime.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now music-jeopardy-realtime.service +sudo systemctl status music-jeopardy-realtime.service +``` + +## 2) Install nginx config + +```bash +sudo cp /repos/music-jeopardy/deploy/nginx/jeopardy.toppit.net.conf /etc/nginx/sites-available/jeopardy.toppit.net +sudo ln -sf /etc/nginx/sites-available/jeopardy.toppit.net /etc/nginx/sites-enabled/jeopardy.toppit.net +sudo nginx -t +sudo systemctl reload nginx +``` + +## 3) Verify + +1. Open `https://jeopardy.toppit.net` +2. Create a session in the app (`Game ID`) +3. Open a second browser/device and join by same `Game ID` diff --git a/deploy/nginx/jeopardy.toppit.net.conf b/deploy/nginx/jeopardy.toppit.net.conf new file mode 100644 index 0000000..22f41e6 --- /dev/null +++ b/deploy/nginx/jeopardy.toppit.net.conf @@ -0,0 +1,27 @@ +server { + listen 443 ssl; + server_name jeopardy.toppit.net; + + root /www/jeopardy; + index index.html; + + # SPA routing for Vue Router / client-side routes + location / { + try_files $uri $uri/ /index.html; + } + + # Realtime session API + websocket + location /realtime/ { + proxy_pass http://127.0.0.1:8787/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + ssl_certificate /etc/letsencrypt/live/jeopardy.toppit.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jeopardy.toppit.net/privkey.pem; +} diff --git a/deploy/systemd/music-jeopardy-realtime.service b/deploy/systemd/music-jeopardy-realtime.service new file mode 100644 index 0000000..02af7aa --- /dev/null +++ b/deploy/systemd/music-jeopardy-realtime.service @@ -0,0 +1,16 @@ +[Unit] +Description=Music Jeopardy Realtime Server +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/repos/music-jeopardy +Environment=NODE_ENV=production +Environment=REALTIME_PORT=8787 +ExecStart=/usr/bin/npm run realtime:server +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/package-lock.json b/package-lock.json index 3fdbe77..95361fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "name": "music-jeopardy", "version": "0.0.0", "dependencies": { - "vue": "^3.2.47" + "vue": "^3.2.47", + "ws": "^8.18.0" }, "devDependencies": { "@vitejs/plugin-vue": "^2.3.4", + "tsx": "^4.7.0", "typescript": "^4.9.5", "vite": "^2.9.16", "vue-tsc": "^1.0.24" @@ -59,6 +61,182 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-loong64": { "version": "0.14.54", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", @@ -75,6 +253,230 @@ "node": ">=12" } }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -627,6 +1029,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -773,6 +1187,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "2.77.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz", @@ -820,6 +1243,82 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -953,6 +1452,26 @@ "peerDependencies": { "vue": "3.5.27" } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "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 + } + } } }, "dependencies": { @@ -983,6 +1502,83 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, + "@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "dev": true, + "optional": true + }, "@esbuild/linux-loong64": { "version": "0.14.54", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", @@ -990,6 +1586,104 @@ "dev": true, "optional": true }, + "@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "dev": true, + "optional": true + }, "@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1333,6 +2027,15 @@ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true }, + "get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1423,6 +2126,12 @@ "supports-preserve-symlinks-flag": "^1.0.0" } }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true + }, "rollup": { "version": "2.77.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.77.3.tgz", @@ -1449,6 +2158,60 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + }, + "dependencies": { + "@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + } + } + }, "typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -1530,6 +2293,12 @@ } } } + }, + "ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "requires": {} } } } diff --git a/package.json b/package.json index 5903df8..263bb99 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "dev": "vite", "build": "vite build", "generate:data": "tsx scripts/generate-data-manifest.ts", - "preview": "vite preview" + "preview": "vite preview", + "realtime:server": "tsx server/realtime-server.ts" }, "dependencies": { - "vue": "^3.2.47" + "vue": "^3.2.47", + "ws": "^8.18.0" }, "devDependencies": { "@vitejs/plugin-vue": "^2.3.4", diff --git a/public/data.json b/public/data.json new file mode 100644 index 0000000..0057396 --- /dev/null +++ b/public/data.json @@ -0,0 +1,472 @@ +[ + { + "name": "HCO", + "categories": [ + { + "name": "Animals in Songs", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/HCO/Animals in Songs/songs/1.mp3", + "answer": "/Data/HCO/Animals in Songs/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/HCO/Animals in Songs/songs/2.mp3", + "answer": "/Data/HCO/Animals in Songs/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/HCO/Animals in Songs/songs/3.mp3", + "answer": "/Data/HCO/Animals in Songs/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/HCO/Animals in Songs/songs/4.mp3", + "answer": "/Data/HCO/Animals in Songs/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/HCO/Animals in Songs/songs/5.mp3", + "answer": "/Data/HCO/Animals in Songs/answers/5.mp3" + } + ] + }, + { + "name": "Billboard Number 1's", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/HCO/Billboard Number 1's/songs/1.mp3", + "answer": "/Data/HCO/Billboard Number 1's/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/HCO/Billboard Number 1's/songs/2.mp3", + "answer": "/Data/HCO/Billboard Number 1's/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/HCO/Billboard Number 1's/songs/3.mp3", + "answer": "/Data/HCO/Billboard Number 1's/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/HCO/Billboard Number 1's/songs/4.mp3", + "answer": "/Data/HCO/Billboard Number 1's/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/HCO/Billboard Number 1's/songs/5.mp3", + "answer": "/Data/HCO/Billboard Number 1's/answers/5.mp3" + } + ] + }, + { + "name": "Kitchen Classics", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/HCO/Kitchen Classics/songs/1.mp3", + "answer": "/Data/HCO/Kitchen Classics/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/HCO/Kitchen Classics/songs/2.mp3", + "answer": "/Data/HCO/Kitchen Classics/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/HCO/Kitchen Classics/songs/3.mp3", + "answer": "/Data/HCO/Kitchen Classics/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/HCO/Kitchen Classics/songs/4.mp3", + "answer": "/Data/HCO/Kitchen Classics/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/HCO/Kitchen Classics/songs/5.mp3", + "answer": "/Data/HCO/Kitchen Classics/answers/5.mp3" + } + ] + }, + { + "name": "Memes", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/HCO/Memes/songs/1.mp3", + "answer": "/Data/HCO/Memes/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/HCO/Memes/songs/2.mp3", + "answer": "/Data/HCO/Memes/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/HCO/Memes/songs/3.mp3", + "answer": "/Data/HCO/Memes/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/HCO/Memes/songs/4.mp3", + "answer": "/Data/HCO/Memes/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/HCO/Memes/songs/5.mp3", + "answer": "/Data/HCO/Memes/answers/5.mp3" + } + ] + }, + { + "name": "Rock", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/HCO/Rock/songs/1.mp3", + "answer": "/Data/HCO/Rock/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/HCO/Rock/songs/2.mp3", + "answer": "/Data/HCO/Rock/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/HCO/Rock/songs/3.mp3", + "answer": "/Data/HCO/Rock/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/HCO/Rock/songs/4.mp3", + "answer": "/Data/HCO/Rock/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/HCO/Rock/songs/5.mp3", + "answer": "/Data/HCO/Rock/answers/5.mp3" + } + ] + } + ] + }, + { + "name": "Misc", + "categories": [ + { + "name": "Bardcore", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Misc/Bardcore/songs/1.mp3", + "answer": "/Data/Misc/Bardcore/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Misc/Bardcore/songs/2.mp3", + "answer": "/Data/Misc/Bardcore/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Misc/Bardcore/songs/3.mp3", + "answer": "/Data/Misc/Bardcore/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Misc/Bardcore/songs/4.mp3", + "answer": "/Data/Misc/Bardcore/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Misc/Bardcore/songs/5.mp3", + "answer": "/Data/Misc/Bardcore/answers/5.mp3" + } + ] + }, + { + "name": "Disney", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Misc/Disney/songs/1.mp3", + "answer": "/Data/Misc/Disney/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Misc/Disney/songs/2.mp3", + "answer": "/Data/Misc/Disney/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Misc/Disney/songs/3.mp3", + "answer": "/Data/Misc/Disney/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Misc/Disney/songs/4.mp3", + "answer": "/Data/Misc/Disney/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Misc/Disney/songs/5.mp3", + "answer": "/Data/Misc/Disney/answers/5.mp3" + } + ] + }, + { + "name": "One Hit Wonders", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Misc/One Hit Wonders/songs/1.mp3", + "answer": "/Data/Misc/One Hit Wonders/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Misc/One Hit Wonders/songs/2.mp3", + "answer": "/Data/Misc/One Hit Wonders/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Misc/One Hit Wonders/songs/3.mp3", + "answer": "/Data/Misc/One Hit Wonders/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Misc/One Hit Wonders/songs/4.mp3", + "answer": "/Data/Misc/One Hit Wonders/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Misc/One Hit Wonders/songs/5.mp3", + "answer": "/Data/Misc/One Hit Wonders/answers/5.mp3" + } + ] + }, + { + "name": "Tal i Titlen", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Misc/Tal i Titlen/songs/1.mp3", + "answer": "/Data/Misc/Tal i Titlen/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Misc/Tal i Titlen/songs/2.mp3", + "answer": "/Data/Misc/Tal i Titlen/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Misc/Tal i Titlen/songs/3.mp3", + "answer": "/Data/Misc/Tal i Titlen/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Misc/Tal i Titlen/songs/4.mp3", + "answer": "/Data/Misc/Tal i Titlen/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Misc/Tal i Titlen/songs/5.mp3", + "answer": "/Data/Misc/Tal i Titlen/answers/5.mp3" + } + ] + } + ] + }, + { + "name": "Years", + "categories": [ + { + "name": "2010's", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Years/2010's/songs/1.mp3", + "answer": "/Data/Years/2010's/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Years/2010's/songs/2.mp3", + "answer": "/Data/Years/2010's/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Years/2010's/songs/3.mp3", + "answer": "/Data/Years/2010's/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Years/2010's/songs/4.mp3", + "answer": "/Data/Years/2010's/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Years/2010's/songs/5.mp3", + "answer": "/Data/Years/2010's/answers/5.mp3" + } + ] + }, + { + "name": "2020", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Years/2020/songs/1.mp3", + "answer": "/Data/Years/2020/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Years/2020/songs/2.mp3", + "answer": "/Data/Years/2020/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Years/2020/songs/3.mp3", + "answer": "/Data/Years/2020/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Years/2020/songs/4.mp3", + "answer": "/Data/Years/2020/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Years/2020/songs/5.mp3", + "answer": "/Data/Years/2020/answers/5.mp3" + } + ] + }, + { + "name": "2024", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Years/2024/songs/1.mp3", + "answer": "/Data/Years/2024/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Years/2024/songs/2.mp3", + "answer": "/Data/Years/2024/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Years/2024/songs/3.mp3", + "answer": "/Data/Years/2024/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Years/2024/songs/4.mp3", + "answer": "/Data/Years/2024/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Years/2024/songs/5.mp3", + "answer": "/Data/Years/2024/answers/5.mp3" + } + ] + }, + { + "name": "Roskilde Gennem Årene", + "clues": [ + { + "number": 1, + "points": 100, + "song": "/Data/Years/Roskilde Gennem Årene/songs/1.mp3", + "answer": "/Data/Years/Roskilde Gennem Årene/answers/1.mp3" + }, + { + "number": 2, + "points": 200, + "song": "/Data/Years/Roskilde Gennem Årene/songs/2.mp3", + "answer": "/Data/Years/Roskilde Gennem Årene/answers/2.mp3" + }, + { + "number": 3, + "points": 300, + "song": "/Data/Years/Roskilde Gennem Årene/songs/3.mp3", + "answer": "/Data/Years/Roskilde Gennem Årene/answers/3.mp3" + }, + { + "number": 4, + "points": 400, + "song": "/Data/Years/Roskilde Gennem Årene/songs/4.mp3", + "answer": "/Data/Years/Roskilde Gennem Årene/answers/4.mp3" + }, + { + "number": 5, + "points": 500, + "song": "/Data/Years/Roskilde Gennem Årene/songs/5.mp3", + "answer": "/Data/Years/Roskilde Gennem Årene/answers/5.mp3" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..ed8a37f --- /dev/null +++ b/server/README.md @@ -0,0 +1,33 @@ +# Realtime Sessions + +This app now supports shared sessions by `gameId` using a lightweight WebSocket server. + +## Run + +```bash +npm run realtime:server +``` + +Default port: `8787` +Override: `REALTIME_PORT=9000 npm run realtime:server` + +## Nginx Proxy + +Proxy `/realtime/*` from your public domain to the realtime server: + +```nginx +location /realtime/ { + proxy_pass http://127.0.0.1:8787/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Notes: +- `proxy_pass` with trailing `/` is important, because frontend calls `/realtime/sessions` and `/realtime/ws`. +- Sessions are kept in memory; restarting the realtime server resets all active game sessions. diff --git a/server/realtime-server.ts b/server/realtime-server.ts new file mode 100644 index 0000000..3a55833 --- /dev/null +++ b/server/realtime-server.ts @@ -0,0 +1,205 @@ +import http from 'http' +import crypto from 'crypto' +import { URL } from 'url' +import { WebSocketServer, type WebSocket } from 'ws' + +type SessionState = Record + +type Session = { + id: string + createdAt: number + updatedAt: number + state: SessionState | null +} + +type ClientInfo = { + gameId: string + socket: WebSocket +} + +const port = Number(process.env.REALTIME_PORT || 8787) +const sessionTtlMs = 1000 * 60 * 60 * 6 + +const sessions = new Map() +const clients = new Map() + +const json = (res: http.ServerResponse, statusCode: number, body: unknown) => { + res.writeHead(statusCode, { + 'content-type': 'application/json', + 'cache-control': 'no-store' + }) + res.end(JSON.stringify(body)) +} + +const createGameId = () => crypto.randomBytes(4).toString('hex').toUpperCase() + +const normalizeGameId = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '') + +const ensureSession = (gameId: string) => { + const now = Date.now() + const existing = sessions.get(gameId) + if (existing) return existing + const session: Session = { + id: gameId, + createdAt: now, + updatedAt: now, + state: null + } + sessions.set(gameId, session) + return session +} + +const broadcast = (gameId: string, payload: unknown, exceptSocket?: WebSocket) => { + const message = JSON.stringify(payload) + clients.forEach((client) => { + if (client.gameId !== gameId) return + if (client.socket.readyState !== client.socket.OPEN) return + if (exceptSocket && client.socket === exceptSocket) return + client.socket.send(message) + }) +} + +const server = http.createServer((req, res) => { + if (!req.url) { + json(res, 400, { error: 'Missing URL' }) + return + } + + const requestUrl = new URL(req.url, `http://localhost:${port}`) + const pathname = requestUrl.pathname + + if (req.method === 'GET' && pathname === '/health') { + json(res, 200, { ok: true }) + return + } + + if (req.method === 'POST' && pathname === '/sessions') { + let body = '' + req.on('data', (chunk) => { + body += chunk.toString() + }) + req.on('end', () => { + let requestedId = '' + if (body.trim()) { + try { + const parsed = JSON.parse(body) as { gameId?: string } + if (typeof parsed.gameId === 'string') { + requestedId = normalizeGameId(parsed.gameId) + } + } catch { + json(res, 400, { error: 'Invalid JSON payload' }) + return + } + } + + let gameId = requestedId + if (!gameId) { + gameId = createGameId() + while (sessions.has(gameId)) gameId = createGameId() + } else if (sessions.has(gameId)) { + json(res, 409, { error: 'Game ID already exists' }) + return + } + + const session = ensureSession(gameId) + json(res, 201, { gameId: session.id }) + }) + return + } + + if (req.method === 'GET' && pathname.startsWith('/sessions/')) { + const gameId = pathname.split('/').filter(Boolean)[1] + if (!gameId) { + json(res, 400, { error: 'Missing gameId' }) + return + } + const session = sessions.get(gameId.toUpperCase()) + if (!session) { + json(res, 404, { error: 'Session not found' }) + return + } + json(res, 200, { gameId: session.id, hasState: !!session.state }) + return + } + + json(res, 404, { error: 'Not found' }) +}) + +const wss = new WebSocketServer({ noServer: true }) + +server.on('upgrade', (req, socket, head) => { + if (!req.url) { + socket.destroy() + return + } + + const requestUrl = new URL(req.url, `http://localhost:${port}`) + if (requestUrl.pathname !== '/ws') { + socket.destroy() + return + } + + const gameId = requestUrl.searchParams.get('gameId')?.toUpperCase() + const clientId = requestUrl.searchParams.get('clientId') || crypto.randomUUID() + + if (!gameId) { + socket.destroy() + return + } + + const session = ensureSession(gameId) + session.updatedAt = Date.now() + + wss.handleUpgrade(req, socket, head, (ws) => { + clients.set(clientId, { gameId, socket: ws }) + + ws.send( + JSON.stringify({ + type: 'session:init', + gameId, + state: session.state + }) + ) + + ws.on('message', (buffer) => { + let parsed: { type?: string; state?: SessionState } | null = null + try { + parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState } + } catch { + return + } + if (!parsed || parsed.type !== 'state:update' || !parsed.state) return + const targetSession = ensureSession(gameId) + targetSession.state = parsed.state + targetSession.updatedAt = Date.now() + + broadcast( + gameId, + { + type: 'state:update', + gameId, + state: targetSession.state + }, + ws + ) + }) + + ws.on('close', () => { + clients.delete(clientId) + }) + }) +}) + +setInterval(() => { + const threshold = Date.now() - sessionTtlMs + sessions.forEach((session, id) => { + if (session.updatedAt >= threshold) return + const hasClient = Array.from(clients.values()).some((client) => client.gameId === id) + if (!hasClient) sessions.delete(id) + }) +}, 1000 * 60) + +server.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Realtime server listening on :${port}`) +}) diff --git a/src/App.vue b/src/App.vue index 436e451..e035ac1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,7 +7,11 @@
Selecting - {{ currentSelector?.name || '—' }} + {{ currentSelector?.name || '-' }} +
+
+ Game ID + {{ gameId }}
@@ -15,6 +19,37 @@

Team Setup

Add teams, name them, and create their color gradients.

+
+

Live Session

+

+ Create a game ID as host, or join an existing game ID as viewer. +

+
+ + + + +
+

+ Status: {{ connectionLabel }} + Error: {{ syncError }} +

+
@@ -23,28 +58,29 @@ v-model="team.name" class="input" placeholder="Team name" + :disabled="!canControlGame" /> - +
-
-
@@ -53,12 +89,12 @@

Select a Game

- Games are loaded from src/Data. Each subfolder becomes a + Games are loaded from public/Data. Each subfolder becomes a playable game.

-

Loading games…

+

Loading games...

@@ -81,6 +117,7 @@ :key="game.name" class="game-card" @click="startGame(game)" + :disabled="!canControlGame" >

{{ game.name }}

{{ game.categories.length }} categories

@@ -88,7 +125,7 @@
- +
@@ -103,7 +140,7 @@ :class="{ active: team.id === currentSelectorId }" :style="teamGradient(team)" @click="awardPoints(team.id)" - :disabled="!canAward" + :disabled="!canAward || !canControlGame" >
{{ team.name }}
{{ team.score }}
@@ -113,7 +150,7 @@
- +
@@ -131,7 +168,7 @@ class="tile" :class="tileClass(cIndex, qIndex)" :style="tileStyle(cIndex, qIndex)" - :disabled="tileDisabled(cIndex, qIndex)" + :disabled="tileDisabled(cIndex, qIndex) || !canControlGame" @click="handleTileClick(cIndex, qIndex)" @contextmenu.prevent="handleTileRightClick(cIndex, qIndex)" > @@ -252,6 +289,8 @@ import { nextTick } from 'vue' import { loadGameData, type Game } from './dataLoader' +type Step = 'setup' | 'select' | 'game' | 'end' + type Team = { id: string name: string @@ -267,6 +306,25 @@ type TileEntry = { type TileMap = Record +type RealtimeState = { + step: Step + teams: Team[] + selectedGameName: string | null + tiles: TileMap + currentTileKey: string | null + currentClipUrl: string + currentSelectorId: string | null + lastAwardedTeamId: string | null + isAnswerClip: boolean +} + +type RealtimeMessage = { + type: 'session:init' | 'state:update' + state: RealtimeState | null +} + +const REALTIME_BASE = '/realtime' + const makeTeam = (index: number): Team => ({ id: `team-${Date.now()}-${index}`, name: `Team ${index + 1}`, @@ -275,20 +333,27 @@ const makeTeam = (index: number): Team => ({ score: 0 }) +const makeClientId = () => { + const segment = Math.random().toString(36).slice(2, 10) + return `client-${Date.now()}-${segment}` +} + +const cloneTiles = (tiles: TileMap): TileMap => JSON.parse(JSON.stringify(tiles)) as TileMap + export default { data() { return { - step: 'setup', + step: 'setup' as Step, teams: [makeTeam(0), makeTeam(1)] as Team[], games: [] as Game[], loadingGames: true, loadError: '', selectedGame: null as Game | null, tiles: {} as TileMap, - currentTileKey: null, + currentTileKey: null as string | null, currentClipUrl: '', - currentSelectorId: null, - lastAwardedTeamId: null, + currentSelectorId: null as string | null, + lastAwardedTeamId: null as string | null, isPlaying: false, pulseLevel: 0, audioContext: null as AudioContext | null, @@ -301,13 +366,33 @@ export default { rayLevel: 0, rayHue: 200, isAnswerClip: false, - tentacleLevels: Array.from({ length: 12 }, () => 0) + tentacleLevels: Array.from({ length: 12 }, () => 0), + gameId: '', + gameIdInput: '', + sessionBusy: false, + socket: null as WebSocket | null, + socketConnected: false, + isHost: false, + clientId: makeClientId(), + syncError: '', + isApplyingRemote: false, + queuedRemoteState: null as RealtimeState | null, + syncTimer: 0 } }, async mounted() { try { this.loadingGames = true this.games = await loadGameData() + if (this.queuedRemoteState) { + await this.applyRemoteState(this.queuedRemoteState) + this.queuedRemoteState = null + } + const fromUrl = new URL(window.location.href).searchParams.get('game') + if (fromUrl) { + this.gameIdInput = fromUrl.toUpperCase() + await this.joinSession() + } } catch (error) { this.loadError = error instanceof Error ? error.message : 'Failed to load games.' } finally { @@ -315,9 +400,18 @@ export default { } }, computed: { + normalizedGameIdInput() { + return this.normalizeGameId(this.gameIdInput) + }, canProceed() { return this.teams.length > 0 && this.teams.every((team) => team.name.trim()) }, + canProceedToSelect() { + return this.canProceed && this.canControlGame && !!this.normalizedGameIdInput && !this.sessionBusy + }, + canControlGame() { + return !this.gameId || this.isHost + }, currentSelector() { return this.teams.find((team) => team.id === this.currentSelectorId) || null }, @@ -333,6 +427,12 @@ export default { .filter((team) => this.winnerIds.includes(team.id)) .map((team) => team.name) .join(', ') + }, + connectionLabel() { + if (!this.gameId) return 'Local mode' + if (this.sessionBusy) return 'Connecting' + if (!this.socketConnected) return this.isHost ? 'Host disconnected' : 'Viewer disconnected' + return this.isHost ? 'Connected as host' : 'Connected as viewer' } }, watch: { @@ -345,6 +445,190 @@ export default { } }, methods: { + normalizeGameId(value: string) { + return value.toUpperCase().replace(/[^A-Z0-9]/g, '') + }, + setGameInUrl(gameId: string) { + const url = new URL(window.location.href) + if (gameId) { + url.searchParams.set('game', gameId) + } else { + url.searchParams.delete('game') + } + window.history.replaceState({}, '', url.toString()) + }, + closeSocket() { + if (this.socket) { + this.socket.close() + this.socket = null + } + this.socketConnected = false + }, + async createSession(preferredId?: string) { + if (this.sessionBusy || this.gameId) return + const requestedId = this.normalizeGameId(preferredId || this.gameIdInput) + if (!requestedId) { + this.syncError = 'Game ID is required' + return + } + this.sessionBusy = true + this.syncError = '' + try { + const response = await fetch(`${REALTIME_BASE}/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ gameId: requestedId }) + }) + if (!response.ok) { + let errorMessage = 'Failed to create session' + try { + const payload = (await response.json()) as { error?: string } + if (payload?.error) errorMessage = payload.error + } catch { + // ignore parse errors and keep default message + } + throw new Error(errorMessage) + } + const payload = (await response.json()) as { gameId: string } + const gameId = this.normalizeGameId(payload.gameId) + this.gameId = gameId + this.gameIdInput = gameId + this.isHost = true + this.setGameInUrl(gameId) + await this.connectSession() + } catch (error) { + this.syncError = error instanceof Error ? error.message : 'Failed to create session' + } finally { + this.sessionBusy = false + } + }, + async joinSession() { + if (this.sessionBusy || this.gameId) return + const normalized = this.normalizeGameId(this.gameIdInput) + if (!normalized) return + this.sessionBusy = true + this.syncError = '' + try { + const existsResponse = await fetch(`${REALTIME_BASE}/sessions/${normalized}`) + if (!existsResponse.ok) throw new Error('Session not found') + this.gameId = normalized + this.gameIdInput = normalized + this.isHost = false + this.setGameInUrl(normalized) + await this.connectSession() + } catch (error) { + this.syncError = error instanceof Error ? error.message : 'Failed to join session' + } finally { + this.sessionBusy = false + } + }, + leaveSession() { + this.closeSocket() + this.gameId = '' + this.gameIdInput = '' + this.isHost = false + this.syncError = '' + this.setGameInUrl('') + }, + async connectSession() { + this.closeSocket() + if (!this.gameId) return + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${protocol}//${window.location.host}${REALTIME_BASE}/ws?gameId=${encodeURIComponent( + this.gameId + )}&clientId=${encodeURIComponent(this.clientId)}` + const socket = new WebSocket(wsUrl) + this.socket = socket + + await new Promise((resolve, reject) => { + socket.onopen = () => { + if (this.socket !== socket) return + this.socketConnected = true + this.syncError = '' + if (this.isHost) this.publishState() + resolve() + } + socket.onerror = () => { + if (this.socket !== socket) return + this.socketConnected = false + this.syncError = 'Realtime connection failed' + reject(new Error('Realtime connection failed')) + } + }).catch(() => {}) + + socket.onmessage = async (event) => { + let message: RealtimeMessage | null = null + try { + message = JSON.parse(event.data as string) as RealtimeMessage + } catch { + return + } + if (!message) return + if (message.type === 'session:init' || message.type === 'state:update') { + if (message.state) await this.applyRemoteState(message.state) + } + } + + socket.onclose = () => { + if (this.socket === socket) { + this.socketConnected = false + } + } + }, + buildRealtimeState(): RealtimeState { + return { + step: this.step, + teams: this.teams.map((team) => ({ ...team })), + selectedGameName: this.selectedGame?.name || null, + tiles: cloneTiles(this.tiles), + currentTileKey: this.currentTileKey, + currentClipUrl: this.currentClipUrl, + currentSelectorId: this.currentSelectorId, + lastAwardedTeamId: this.lastAwardedTeamId, + isAnswerClip: this.isAnswerClip + } + }, + publishState() { + if (!this.isHost || !this.socketConnected || !this.socket) return + if (this.isApplyingRemote) return + const payload = { + type: 'state:update', + state: this.buildRealtimeState() + } + this.socket.send(JSON.stringify(payload)) + }, + queueStateSync() { + if (!this.isHost || !this.socketConnected || !this.socket) return + if (this.isApplyingRemote) return + if (this.syncTimer) window.clearTimeout(this.syncTimer) + this.syncTimer = window.setTimeout(() => { + this.publishState() + }, 80) + }, + async applyRemoteState(state: RealtimeState) { + if (this.isHost) return + if (this.loadingGames) { + this.queuedRemoteState = state + return + } + this.isApplyingRemote = true + try { + const selected = state.selectedGameName + ? this.games.find((game) => game.name === state.selectedGameName) || null + : null + this.step = state.step + this.teams = state.teams.map((team) => ({ ...team })) + this.selectedGame = selected + this.tiles = cloneTiles(state.tiles) + this.currentTileKey = state.currentTileKey + this.currentClipUrl = state.currentClipUrl + this.currentSelectorId = state.currentSelectorId + this.lastAwardedTeamId = state.lastAwardedTeamId + this.isAnswerClip = state.isAnswerClip + } finally { + this.isApplyingRemote = false + } + }, getPlayer() { return this.$refs.player as HTMLAudioElement | undefined }, @@ -496,16 +780,32 @@ export default { } }, addTeam() { + if (!this.canControlGame) return this.teams.push(makeTeam(this.teams.length)) + this.queueStateSync() }, removeTeam(id: string) { + if (!this.canControlGame) return if (this.teams.length === 1) return this.teams = this.teams.filter((team) => team.id !== id) + this.queueStateSync() }, - goToSelect() { + async goToSelect() { + if (!this.canControlGame) return + const requestedId = this.normalizeGameId(this.gameIdInput) + if (!requestedId) { + this.syncError = 'Game ID is required' + return + } + if (!this.gameId) { + await this.createSession(requestedId) + if (!this.gameId) return + } this.step = 'select' + this.queueStateSync() }, startGame(game: Game) { + if (!this.canControlGame) return this.selectedGame = game this.tiles = {} this.currentTileKey = null @@ -515,8 +815,10 @@ export default { const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)] this.currentSelectorId = randomTeam?.id || null this.step = 'game' + this.queueStateSync() }, resetGame() { + if (!this.canControlGame) return this.step = 'select' this.selectedGame = null this.tiles = {} @@ -525,6 +827,7 @@ export default { this.lastAwardedTeamId = null this.isAnswerClip = false this.teardownAudio() + this.queueStateSync() }, tileKey(cIndex: number, qIndex: number) { return `${cIndex}-${qIndex}` @@ -558,6 +861,7 @@ export default { return this.teamGradient(team) }, async handleTileClick(cIndex: number, qIndex: number) { + if (!this.canControlGame) return const key = this.tileKey(cIndex, qIndex) const status = this.tileStatus(cIndex, qIndex) const clue = this.selectedGame?.categories[cIndex].clues[qIndex] @@ -577,6 +881,7 @@ export default { player.load() player.play().catch(() => {}) } + this.queueStateSync() return } @@ -584,6 +889,7 @@ export default { this.tiles[key].status = 'paused' const player = this.getPlayer() player?.pause() + this.queueStateSync() return } @@ -602,6 +908,7 @@ export default { player.play().catch(() => {}) } } + this.queueStateSync() return } @@ -614,9 +921,11 @@ export default { this.currentClipUrl = '' this.lastAwardedTeamId = null this.checkEnd() + this.queueStateSync() } }, handleTileRightClick(cIndex: number, qIndex: number) { + if (!this.canControlGame) return const key = this.tileKey(cIndex, qIndex) const status = this.tileStatus(cIndex, qIndex) if (key !== this.currentTileKey) return @@ -625,6 +934,7 @@ export default { this.tiles[key].status = 'playing' const player = this.getPlayer() player?.play().catch(() => {}) + this.queueStateSync() return } @@ -637,9 +947,11 @@ export default { player?.pause() this.teardownAudio() this.checkEnd() + this.queueStateSync() } }, awardPoints(teamId: string) { + if (!this.canControlGame) return if (!this.canAward) return const key = this.currentTileKey if (!key || !this.selectedGame) return @@ -652,6 +964,7 @@ export default { if (this.tiles[key]) { this.tiles[key].lastTeamId = teamId } + this.queueStateSync() }, checkEnd() { if (!this.selectedGame) return @@ -669,6 +982,9 @@ export default { }, unmounted() { this.teardownAudio() + this.closeSocket() + if (this.syncTimer) window.clearTimeout(this.syncTimer) } } + diff --git a/src/styles.css b/src/styles.css index a2948b0..9e71d58 100644 --- a/src/styles.css +++ b/src/styles.css @@ -72,6 +72,10 @@ code { font-weight: 600; } +.session-pill { + border-color: rgba(61, 214, 255, 0.5); +} + .app-main { flex: 1; } @@ -96,6 +100,38 @@ code { color: rgba(255, 255, 255, 0.7); } +.session-box { + margin-top: 18px; + margin-bottom: 20px; + padding: 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); +} + +.session-box h3 { + margin: 0 0 6px; +} + +.session-row { + display: flex; + gap: 10px; + align-items: center; +} + +.session-row .input { + max-width: 220px; +} + +.session-meta { + margin: 12px 0 0; + display: flex; + gap: 14px; + flex-wrap: wrap; + color: rgba(255, 255, 255, 0.75); + font-size: 0.9rem; +} + .teams-grid, .games-grid { display: grid; @@ -171,6 +207,11 @@ input[type='color'] { background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12)); } +.add-card:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .game-card { cursor: pointer; transition: transform 0.2s ease; @@ -534,4 +575,8 @@ audio.hidden-audio { flex-direction: column; align-items: flex-start; } + + .session-row { + flex-wrap: wrap; + } }