Compare commits
14 Commits
master
...
feature-mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92a0c861f | ||
|
|
45c8123c3b | ||
|
|
2ddddfbaf1 | ||
|
|
083ca153ac | ||
|
|
9b6dd0b8e8 | ||
|
|
40d2e928aa | ||
|
|
551b5a76d8 | ||
|
|
f440118fef | ||
|
|
993b1a2c13 | ||
|
|
7c12d25367 | ||
|
|
a16d340cb7 | ||
|
|
474be5d833 | ||
|
|
7a7adc5e91 | ||
|
|
9945f8163e |
56
.gitea/workflows/on-push-feature.yml
Normal file
56
.gitea/workflows/on-push-feature.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
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/
|
||||
- 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
|
||||
48
.gitea/workflows/on-push-master.yml
Normal file
48
.gitea/workflows/on-push-master.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
@@ -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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
25
deploy/SETUP.md
Normal file
25
deploy/SETUP.md
Normal file
@@ -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`
|
||||
27
deploy/nginx/jeopardy.toppit.net.conf
Normal file
27
deploy/nginx/jeopardy.toppit.net.conf
Normal file
@@ -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;
|
||||
}
|
||||
16
deploy/systemd/music-jeopardy-realtime.service
Normal file
16
deploy/systemd/music-jeopardy-realtime.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Music Jeopardy Realtime Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/repos/music-jeopardy
|
||||
Environment=NODE_ENV=production
|
||||
Environment=REALTIME_PORT=8787
|
||||
ExecStart=/usr/bin/npm --prefix /root/repos/music-jeopardy run realtime:server
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
771
package-lock.json
generated
771
package-lock.json
generated
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
472
public/data.json
Normal file
472
public/data.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
33
server/README.md
Normal file
33
server/README.md
Normal file
@@ -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.
|
||||
225
server/realtime-server.ts
Normal file
225
server/realtime-server.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import http from 'http'
|
||||
import crypto from 'crypto'
|
||||
import { URL } from 'url'
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
|
||||
type SessionState = Record<string, unknown>
|
||||
|
||||
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<string, Session>()
|
||||
const clients = new Map<string, ClientInfo>()
|
||||
|
||||
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; teamId?: string } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(buffer.toString()) as {
|
||||
type?: string
|
||||
state?: SessionState
|
||||
teamId?: string
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed) return
|
||||
|
||||
if (parsed.type === 'state:update' && parsed.state) {
|
||||
const targetSession = ensureSession(gameId)
|
||||
targetSession.state = parsed.state
|
||||
targetSession.updatedAt = Date.now()
|
||||
|
||||
broadcast(
|
||||
gameId,
|
||||
{
|
||||
type: 'state:update',
|
||||
gameId,
|
||||
state: targetSession.state
|
||||
},
|
||||
ws
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.type === 'guess:request' && parsed.teamId) {
|
||||
broadcast(
|
||||
gameId,
|
||||
{
|
||||
type: 'guess:request',
|
||||
gameId,
|
||||
teamId: parsed.teamId
|
||||
},
|
||||
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}`)
|
||||
})
|
||||
579
src/App.vue
579
src/App.vue
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="app" :class="{ suspended: viewerSuspended }">
|
||||
<header class="app-header">
|
||||
<div>
|
||||
<p class="eyebrow">Music Jeopardy</p>
|
||||
<h1>Track the Beat, Claim the Points</h1>
|
||||
<h1>Music Jeopardy</h1>
|
||||
</div>
|
||||
<div v-if="step === 'game'" class="selector-pill">
|
||||
<span class="label">Selecting</span>
|
||||
<span class="value">{{ currentSelector?.name || '—' }}</span>
|
||||
<span class="value">{{ currentSelector?.name || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="gameId" class="selector-pill session-pill">
|
||||
<span class="label">Game ID</span>
|
||||
<span class="value">{{ gameId }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -15,6 +18,37 @@
|
||||
<section v-if="step === 'setup'" class="panel">
|
||||
<h2>Team Setup</h2>
|
||||
<p class="hint">Add teams, name them, and create their color gradients.</p>
|
||||
<div class="session-box">
|
||||
<h3>Live Session</h3>
|
||||
<p class="hint">
|
||||
Create a game ID as host, or join an existing game ID as viewer.
|
||||
</p>
|
||||
<div class="session-row">
|
||||
<input
|
||||
v-model.trim="gameIdInput"
|
||||
class="input"
|
||||
placeholder="Game ID"
|
||||
:disabled="sessionBusy || !!gameId"
|
||||
/>
|
||||
<button class="ghost" @click="createSession()" :disabled="sessionBusy || !!gameId || !normalizedGameIdInput">
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
@click="joinSession"
|
||||
:disabled="sessionBusy || !!gameId || !normalizedGameIdInput"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
<button class="ghost" v-if="gameId" @click="leaveSession" :disabled="sessionBusy">
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
<p class="session-meta">
|
||||
<span>Status: {{ connectionLabel }}</span>
|
||||
<span v-if="syncError">Error: {{ syncError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="teams-grid">
|
||||
<div v-for="team in teams" :key="team.id" class="team-card">
|
||||
@@ -23,28 +57,29 @@
|
||||
v-model="team.name"
|
||||
class="input"
|
||||
placeholder="Team name"
|
||||
:disabled="!canControlGame"
|
||||
/>
|
||||
<button class="ghost" @click="removeTeam(team.id)">Remove</button>
|
||||
<button class="ghost" @click="removeTeam(team.id)" :disabled="!canControlGame">Remove</button>
|
||||
</div>
|
||||
<div class="gradient-preview" :style="teamGradient(team)"></div>
|
||||
<div class="color-row">
|
||||
<label>
|
||||
Color A
|
||||
<input v-model="team.colorA" type="color" />
|
||||
<input v-model="team.colorA" type="color" :disabled="!canControlGame" />
|
||||
</label>
|
||||
<label>
|
||||
Color B
|
||||
<input v-model="team.colorB" type="color" />
|
||||
<input v-model="team.colorB" type="color" :disabled="!canControlGame" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-card" @click="addTeam">
|
||||
<button class="add-card" @click="addTeam" :disabled="!canControlGame">
|
||||
<span>+ Add Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" :disabled="!canProceed" @click="goToSelect">
|
||||
<button class="primary" :disabled="!canProceedToSelect" @click="goToSelect">
|
||||
Next: Choose Game
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,12 +88,12 @@
|
||||
<section v-if="step === 'select'" class="panel">
|
||||
<h2>Select a Game</h2>
|
||||
<p class="hint">
|
||||
Games are loaded from <code>src/Data</code>. Each subfolder becomes a
|
||||
Games are loaded from <code>public/Data</code>. Each subfolder becomes a
|
||||
playable game.
|
||||
</p>
|
||||
|
||||
<div v-if="loadingGames" class="empty-state">
|
||||
<p>Loading games…</p>
|
||||
<p>Loading games...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="empty-state">
|
||||
@@ -81,6 +116,7 @@
|
||||
:key="game.name"
|
||||
class="game-card"
|
||||
@click="startGame(game)"
|
||||
:disabled="!canControlGame"
|
||||
>
|
||||
<h3>{{ game.name }}</h3>
|
||||
<p>{{ game.categories.length }} categories</p>
|
||||
@@ -88,22 +124,34 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="ghost" @click="step = 'setup'">Back</button>
|
||||
<button class="ghost" @click="step = 'setup'" :disabled="!canControlGame">Back</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="step === 'game'" class="game-layout">
|
||||
<aside class="scoreboard">
|
||||
<h2>Teams</h2>
|
||||
<div v-if="!canControlGame" class="viewer-team-select">
|
||||
<label for="viewer-team">Playing For</label>
|
||||
<select id="viewer-team" v-model="viewerTeamId" class="input">
|
||||
<option value="">Select team</option>
|
||||
<option v-for="team in teams" :key="`viewer-${team.id}`" :value="team.id">
|
||||
{{ team.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="team-list">
|
||||
<button
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
class="team-score-card"
|
||||
:class="{ active: team.id === currentSelectorId }"
|
||||
:class="{
|
||||
active: team.id === currentSelectorId,
|
||||
viewerTeam: !canControlGame && !!viewerTeamId && team.id === viewerTeamId
|
||||
}"
|
||||
:style="teamGradient(team)"
|
||||
@click="awardPoints(team.id)"
|
||||
:disabled="!canAward"
|
||||
:disabled="!canAward || !canControlGame"
|
||||
>
|
||||
<div class="team-name">{{ team.name }}</div>
|
||||
<div class="team-score">{{ team.score }}</div>
|
||||
@@ -113,11 +161,14 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="ghost" @click="resetGame">Reset Game</button>
|
||||
<button class="ghost" @click="resetGame" :disabled="!canControlGame">Reset Game</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="board">
|
||||
<div v-if="isGuessSuspended && !!guessingTeamId" class="board-guessing-banner">
|
||||
{{ guessingTeamLabel }} is guessing
|
||||
</div>
|
||||
<div class="board-grid">
|
||||
<div
|
||||
v-for="(category, cIndex) in selectedGame?.categories || []"
|
||||
@@ -131,7 +182,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)"
|
||||
>
|
||||
@@ -148,7 +199,7 @@
|
||||
Guessed
|
||||
</span>
|
||||
<span v-else-if="tileStatus(cIndex, qIndex) === 'won'">
|
||||
Won
|
||||
{{ winningTeamName(cIndex, qIndex) }}
|
||||
</span>
|
||||
<span v-else>Skipped</span>
|
||||
</button>
|
||||
@@ -214,6 +265,21 @@
|
||||
@pause="handlePlayerPause"
|
||||
@ended="handlePlayerPause"
|
||||
></audio>
|
||||
<div v-if="viewerGuessVisible || showEnableAudio" class="viewer-actions">
|
||||
<button v-if="showEnableAudio" class="primary enable-audio" @click="enableViewerAudio">
|
||||
Tap To Enable Audio
|
||||
</button>
|
||||
<button class="primary viewer-guess" :disabled="!canViewerGuess" @click="requestGuessStop">
|
||||
Stop Song And Guess
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canControlGame && getCurrentTileStatus() === 'paused' && !!guessingTeamId"
|
||||
class="primary host-reveal"
|
||||
@click="revealPausedGuess"
|
||||
>
|
||||
Reveal Answer
|
||||
</button>
|
||||
<div v-if="!currentClipUrl" class="player-empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +311,9 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div v-if="viewerSuspended" class="guess-overlay">
|
||||
<p>{{ guessingTeamLabel }} is guessing</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -252,6 +321,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 +338,27 @@ type TileEntry = {
|
||||
|
||||
type TileMap = Record<string, TileEntry>
|
||||
|
||||
type RealtimeState = {
|
||||
step: Step
|
||||
teams: Team[]
|
||||
selectedGameName: string | null
|
||||
tiles: TileMap
|
||||
currentTileKey: string | null
|
||||
currentClipUrl: string
|
||||
currentSelectorId: string | null
|
||||
lastAwardedTeamId: string | null
|
||||
isAnswerClip: boolean
|
||||
guessingTeamId: string | null
|
||||
}
|
||||
|
||||
type RealtimeMessage = {
|
||||
type: 'session:init' | 'state:update' | 'guess:request'
|
||||
state?: RealtimeState | null
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
const REALTIME_BASE = '/realtime'
|
||||
|
||||
const makeTeam = (index: number): Team => ({
|
||||
id: `team-${Date.now()}-${index}`,
|
||||
name: `Team ${index + 1}`,
|
||||
@@ -275,20 +367,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 +400,38 @@ 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,
|
||||
audioUnlocked: true,
|
||||
latestRemoteState: null as RealtimeState | null,
|
||||
viewerTeamId: '',
|
||||
guessingTeamId: null as string | null,
|
||||
pauseTransitionLockUntil: 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 +439,44 @@ 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
|
||||
},
|
||||
showEnableAudio() {
|
||||
return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked
|
||||
},
|
||||
canViewerGuess() {
|
||||
return (
|
||||
!this.canControlGame &&
|
||||
!!this.viewerTeamId &&
|
||||
this.getCurrentTileStatus() === 'playing'
|
||||
)
|
||||
},
|
||||
viewerGuessVisible() {
|
||||
return !this.canControlGame && this.getCurrentTileStatus() === 'playing'
|
||||
},
|
||||
canControlGame() {
|
||||
return !this.gameId || this.isHost
|
||||
},
|
||||
isGuessSuspended() {
|
||||
return this.step === 'game' && this.getCurrentTileStatus() === 'paused' && !!this.guessingTeamId
|
||||
},
|
||||
viewerSuspended() {
|
||||
return this.isGuessSuspended && !this.canControlGame
|
||||
},
|
||||
guessingTeamLabel() {
|
||||
if (this.guessingTeamId) {
|
||||
const team = this.teams.find((candidate) => candidate.id === this.guessingTeamId)
|
||||
if (team?.name?.trim()) return team.name.trim()
|
||||
}
|
||||
return 'A team'
|
||||
},
|
||||
currentSelector() {
|
||||
return this.teams.find((team) => team.id === this.currentSelectorId) || null
|
||||
},
|
||||
@@ -333,6 +492,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 +510,282 @@ 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.audioUnlocked = 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')
|
||||
const sessionInfo = (await existsResponse.json()) as { hasState?: boolean }
|
||||
if (!sessionInfo.hasState) {
|
||||
throw new Error('Game is not open yet. Host must press Next: Choose Game first.')
|
||||
}
|
||||
this.gameId = normalized
|
||||
this.gameIdInput = normalized
|
||||
this.isHost = false
|
||||
this.audioUnlocked = false
|
||||
this.viewerTeamId = ''
|
||||
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.audioUnlocked = true
|
||||
this.latestRemoteState = null
|
||||
this.viewerTeamId = ''
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
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
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
if (message.type === 'guess:request') {
|
||||
await this.handleRemoteGuessRequest(message.teamId || '')
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) {
|
||||
this.socketConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((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(() => {})
|
||||
},
|
||||
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,
|
||||
guessingTeamId: this.guessingTeamId
|
||||
}
|
||||
},
|
||||
publishState() {
|
||||
if (!this.isHost || !this.socketConnected || !this.socket) return
|
||||
if (this.isApplyingRemote) return
|
||||
if (this.step === 'setup') 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.latestRemoteState = state
|
||||
const previousClipUrl = this.currentClipUrl
|
||||
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
|
||||
this.guessingTeamId = state.guessingTeamId || null
|
||||
if (this.viewerTeamId && !this.teams.some((team) => team.id === this.viewerTeamId)) {
|
||||
this.viewerTeamId = ''
|
||||
}
|
||||
} finally {
|
||||
this.isApplyingRemote = false
|
||||
}
|
||||
await nextTick()
|
||||
this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl)
|
||||
},
|
||||
async enableViewerAudio() {
|
||||
if (this.canControlGame) return
|
||||
this.audioUnlocked = true
|
||||
this.ensureAudioContext()
|
||||
await nextTick()
|
||||
this.syncRemotePlayback(false)
|
||||
},
|
||||
requestGuessStop() {
|
||||
if (this.canControlGame) return
|
||||
if (!this.socketConnected || !this.socket) return
|
||||
if (!this.viewerTeamId) return
|
||||
if (this.getCurrentTileStatus() !== 'playing') return
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'guess:request',
|
||||
teamId: this.viewerTeamId
|
||||
})
|
||||
)
|
||||
},
|
||||
async handleRemoteGuessRequest(teamId: string) {
|
||||
if (!this.canControlGame || !this.isHost) return
|
||||
if (!teamId) return
|
||||
if (!this.currentTileKey || !this.selectedGame) return
|
||||
const status = this.getCurrentTileStatus()
|
||||
if (status !== 'playing') return
|
||||
const key = this.currentTileKey
|
||||
this.tiles[key].status = 'paused'
|
||||
this.lastAwardedTeamId = null
|
||||
this.guessingTeamId = teamId
|
||||
this.pauseTransitionLockUntil = Date.now() + 1200
|
||||
this.isAnswerClip = false
|
||||
const player = this.getPlayer()
|
||||
player?.pause()
|
||||
this.queueStateSync()
|
||||
},
|
||||
getCurrentTileStatus() {
|
||||
if (!this.currentTileKey) return 'available'
|
||||
return this.tiles[this.currentTileKey]?.status || 'available'
|
||||
},
|
||||
syncRemotePlayback(forceReload: boolean) {
|
||||
if (this.canControlGame) return
|
||||
const player = this.getPlayer()
|
||||
if (!player) return
|
||||
const tileStatus = this.getCurrentTileStatus()
|
||||
|
||||
if (!this.currentClipUrl || tileStatus === 'won' || tileStatus === 'void') {
|
||||
player.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (forceReload) {
|
||||
player.currentTime = 0
|
||||
player.load()
|
||||
}
|
||||
|
||||
if (tileStatus === 'paused') {
|
||||
player.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (tileStatus === 'playing' || tileStatus === 'guessed') {
|
||||
if (!this.audioUnlocked) return
|
||||
this.ensureAudioContext()
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
},
|
||||
getPlayer() {
|
||||
return this.$refs.player as HTMLAudioElement | undefined
|
||||
},
|
||||
@@ -359,7 +800,11 @@ export default {
|
||||
setupAudioGraph() {
|
||||
const player = this.getPlayer()
|
||||
if (!player) return
|
||||
try {
|
||||
this.ensureAudioContext()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.analyser) {
|
||||
this.analyser.disconnect()
|
||||
@@ -373,7 +818,11 @@ export default {
|
||||
if (this.mediaSource) {
|
||||
this.mediaSource.disconnect()
|
||||
}
|
||||
try {
|
||||
this.mediaSource = context.createMediaElementSource(player)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
this.mediaElement = player
|
||||
}
|
||||
|
||||
@@ -471,6 +920,7 @@ export default {
|
||||
handlePlayerPlay() {
|
||||
this.isPlaying = true
|
||||
this.startPulse()
|
||||
if (this.isHost) this.queueStateSync()
|
||||
},
|
||||
handlePlayerPause() {
|
||||
this.isPlaying = false
|
||||
@@ -479,6 +929,7 @@ export default {
|
||||
this.rafId = 0
|
||||
}
|
||||
this.pulseLevel = 0
|
||||
if (this.isHost) this.queueStateSync()
|
||||
},
|
||||
togglePlayback() {
|
||||
const player = this.getPlayer()
|
||||
@@ -496,27 +947,47 @@ 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
|
||||
this.currentClipUrl = ''
|
||||
this.lastAwardedTeamId = null
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.teams = this.teams.map((team) => ({ ...team, score: 0 }))
|
||||
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 = {}
|
||||
@@ -524,7 +995,10 @@ export default {
|
||||
this.currentClipUrl = ''
|
||||
this.lastAwardedTeamId = null
|
||||
this.isAnswerClip = false
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.teardownAudio()
|
||||
this.queueStateSync()
|
||||
},
|
||||
tileKey(cIndex: number, qIndex: number) {
|
||||
return `${cIndex}-${qIndex}`
|
||||
@@ -557,7 +1031,15 @@ export default {
|
||||
if (!team) return {}
|
||||
return this.teamGradient(team)
|
||||
},
|
||||
winningTeamName(cIndex: number, qIndex: number) {
|
||||
const key = this.tileKey(cIndex, qIndex)
|
||||
const teamId = this.tiles[key]?.lastTeamId
|
||||
if (!teamId) return 'Won'
|
||||
const team = this.teams.find((candidate) => candidate.id === teamId)
|
||||
return team?.name?.trim() || 'Won'
|
||||
},
|
||||
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]
|
||||
@@ -569,6 +1051,8 @@ export default {
|
||||
this.currentTileKey = key
|
||||
this.currentClipUrl = encodeURI(clue.song)
|
||||
this.isAnswerClip = false
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
await nextTick()
|
||||
const player = this.getPlayer()
|
||||
if (player) {
|
||||
@@ -577,18 +1061,26 @@ export default {
|
||||
player.load()
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'playing') {
|
||||
this.tiles[key].status = 'paused'
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
const player = this.getPlayer()
|
||||
player?.pause()
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'paused') {
|
||||
if (this.guessingTeamId) return
|
||||
if (Date.now() < this.pauseTransitionLockUntil) return
|
||||
this.tiles[key].status = 'guessed'
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.lastAwardedTeamId = null
|
||||
if (clue.answer) {
|
||||
this.currentClipUrl = encodeURI(clue.answer)
|
||||
@@ -602,6 +1094,7 @@ export default {
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -613,18 +1106,49 @@ export default {
|
||||
this.currentTileKey = null
|
||||
this.currentClipUrl = ''
|
||||
this.lastAwardedTeamId = null
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.checkEnd()
|
||||
this.queueStateSync()
|
||||
}
|
||||
},
|
||||
async revealPausedGuess() {
|
||||
if (!this.canControlGame) return
|
||||
if (!this.currentTileKey || !this.selectedGame) return
|
||||
if (this.getCurrentTileStatus() !== 'paused') return
|
||||
const [cIndex, qIndex] = this.currentTileKey.split('-').map(Number)
|
||||
const clue = this.selectedGame.categories[cIndex].clues[qIndex]
|
||||
this.tiles[this.currentTileKey].status = 'guessed'
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.lastAwardedTeamId = null
|
||||
if (clue.answer) {
|
||||
this.currentClipUrl = encodeURI(clue.answer)
|
||||
this.isAnswerClip = true
|
||||
await nextTick()
|
||||
const player = this.getPlayer()
|
||||
if (player) {
|
||||
this.ensureAudioContext()
|
||||
player.currentTime = 0
|
||||
player.load()
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
if (status === 'paused') {
|
||||
this.tiles[key].status = 'playing'
|
||||
this.pauseTransitionLockUntil = 0
|
||||
this.guessingTeamId = null
|
||||
const player = this.getPlayer()
|
||||
player?.play().catch(() => {})
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -633,13 +1157,17 @@ export default {
|
||||
this.currentTileKey = null
|
||||
this.currentClipUrl = ''
|
||||
this.isAnswerClip = false
|
||||
this.guessingTeamId = null
|
||||
this.pauseTransitionLockUntil = 0
|
||||
const player = this.getPlayer()
|
||||
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 +1180,7 @@ export default {
|
||||
if (this.tiles[key]) {
|
||||
this.tiles[key].lastTeamId = teamId
|
||||
}
|
||||
this.queueStateSync()
|
||||
},
|
||||
checkEnd() {
|
||||
if (!this.selectedGame) return
|
||||
@@ -664,11 +1193,15 @@ export default {
|
||||
const finished = allTiles.every((status) => status === 'won' || status === 'void')
|
||||
if (finished) {
|
||||
this.step = 'end'
|
||||
this.guessingTeamId = null
|
||||
}
|
||||
}
|
||||
},
|
||||
unmounted() {
|
||||
this.teardownAudio()
|
||||
this.closeSocket()
|
||||
if (this.syncTimer) window.clearTimeout(this.syncTimer)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
240
src/styles.css
240
src/styles.css
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
color: #f6f4ef;
|
||||
background: #0d0f1c;
|
||||
color: #f9f6ff;
|
||||
background: #080512;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -11,7 +11,12 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #1f2a44, #0b0f1f 60%);
|
||||
background:
|
||||
radial-gradient(circle at 14% 18%, rgba(255, 112, 197, 0.26), transparent 32%),
|
||||
radial-gradient(circle at 82% 16%, rgba(72, 221, 255, 0.24), transparent 34%),
|
||||
radial-gradient(circle at 30% 80%, rgba(151, 94, 255, 0.26), transparent 36%),
|
||||
linear-gradient(165deg, #0b071a 0%, #130726 45%, #070d2a 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -27,6 +32,68 @@ code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(
|
||||
115deg,
|
||||
transparent 0%,
|
||||
transparent 45%,
|
||||
rgba(255, 255, 255, 0.07) 49%,
|
||||
transparent 53%,
|
||||
transparent 100%
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0px,
|
||||
transparent 42px,
|
||||
rgba(255, 255, 255, 0.03) 43px,
|
||||
transparent 44px
|
||||
);
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.app-main {
|
||||
transition: filter 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.app.suspended .app-header,
|
||||
.app.suspended .app-main {
|
||||
filter: grayscale(1) brightness(0.55);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.guess-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(8, 12, 26, 0.35);
|
||||
backdrop-filter: blur(2px) grayscale(0.15);
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.guess-overlay p {
|
||||
margin: 0;
|
||||
padding: 16px 24px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(13, 17, 35, 0.88);
|
||||
color: #ffffff;
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: clamp(2.1rem, 4vw, 3.3rem);
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -39,8 +106,13 @@ code {
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: clamp(2.2rem, 2.5vw, 3rem);
|
||||
letter-spacing: 0.08em;
|
||||
font-size: clamp(3.1rem, 6vw, 5.2rem);
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 0.95;
|
||||
color: #ffffff;
|
||||
text-shadow:
|
||||
0 0 14px rgba(255, 112, 197, 0.5),
|
||||
0 0 22px rgba(87, 205, 255, 0.45);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@@ -52,13 +124,14 @@ code {
|
||||
}
|
||||
|
||||
.selector-pill {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.selector-pill .label {
|
||||
@@ -72,16 +145,25 @@ code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-pill {
|
||||
border-color: rgba(255, 112, 197, 0.8);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(8, 12, 26, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)),
|
||||
rgba(7, 10, 26, 0.76);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 18px 46px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
@@ -96,6 +178,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 +285,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;
|
||||
@@ -192,18 +311,19 @@ button {
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, #3dd6ff, #fbd72b);
|
||||
color: #0d0f1c;
|
||||
background: linear-gradient(135deg, #ff6fc8, #8f7bff 55%, #4ed4ff);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgba(143, 123, 255, 0.35);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
color: inherit;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
@@ -224,10 +344,13 @@ button {
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
background: rgba(8, 12, 26, 0.8);
|
||||
background:
|
||||
linear-gradient(165deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)),
|
||||
rgba(7, 10, 26, 0.84);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 30px rgba(157, 95, 255, 0.18);
|
||||
}
|
||||
|
||||
.scoreboard h2 {
|
||||
@@ -241,6 +364,17 @@ button {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viewer-team-select {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.viewer-team-select label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.team-score-card {
|
||||
width: 100%;
|
||||
border: none;
|
||||
@@ -255,6 +389,10 @@ button {
|
||||
outline: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.team-score-card.viewerTeam {
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6) inset;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -282,6 +420,23 @@ button {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.board-guessing-banner {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
background: rgba(12, 15, 30, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: clamp(1.1rem, 2vw, 1.6rem);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
@@ -335,6 +490,9 @@ button {
|
||||
.tile.won {
|
||||
background: #2a2f3f;
|
||||
color: #0d0f1c;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tile.void {
|
||||
@@ -498,6 +656,43 @@ audio.hidden-audio {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.enable-audio {
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.viewer-guess {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(135deg, #ffce3a, #ff6a3a);
|
||||
box-shadow: 0 12px 30px rgba(255, 106, 58, 0.35);
|
||||
}
|
||||
|
||||
.viewer-guess:disabled {
|
||||
background: linear-gradient(135deg, #8f8f8f, #666666);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.host-reveal {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 4;
|
||||
background: linear-gradient(135deg, #7df58f, #36bf62);
|
||||
}
|
||||
|
||||
.end-panel {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -534,4 +729,17 @@ audio.hidden-audio {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer-actions {
|
||||
flex-direction: column;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.viewer-actions .primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user