From 2b0c831399a77220b5fa3a52870ee0a93a463572 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 8 Dec 2025 06:17:30 +0100 Subject: [PATCH] feat: Add multiplayer room system with real-time consensus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WebSocket server for real-time boredom synchronization - Add private room creation with 6-character codes - Add mobile-optimized join page for phone participants - Add QR code sharing for easy room joining - Add individual participant mini-dials with color coding - Add simulated bots for global room demo - Update nginx config to proxy /api and /ws endpoints - Add react-router-dom for multi-page navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 39 +- docker-compose.yml | 21 + package-lock.json | 1452 +++++++++++++++++----------------------- package.json | 4 +- postcss.config.js | 2 +- public/favicon.svg | 3 + public/index.html | 48 +- server/Dockerfile | 12 + server/index.js | 299 +++++++++ server/package.json | 12 + src/App.css | 699 ++++++++++++++++++- src/App.js | 576 +++++++++++++++- src/components/Dial.js | 331 +++++++++ src/index.css | 33 +- 14 files changed, 2618 insertions(+), 913 deletions(-) create mode 100644 public/favicon.svg create mode 100644 server/Dockerfile create mode 100644 server/index.js create mode 100644 server/package.json create mode 100644 src/components/Dial.js diff --git a/Dockerfile b/Dockerfile index 367d3c8..b4771fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN npm ci COPY . . -# Remove tailwind postcss config and delete tailwindcss to prevent auto-detection +# Remove tailwind postcss config RUN rm -f postcss.config.js tailwind.config.js RUN npm uninstall tailwindcss @tailwindcss/postcss 2>/dev/null || true @@ -18,15 +18,48 @@ RUN npm run build FROM nginx:alpine COPY --from=builder /app/build /usr/share/nginx/html -COPY < /etc/nginx/conf.d/default.conf << 'EOF' +upstream websocket { + server boredom-ws:3001; +} + server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; + # WebSocket proxy + location /ws { + proxy_pass http://websocket; + 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_read_timeout 86400; + } + + # Health check endpoint + location /health { + proxy_pass http://websocket/health; + } + + # API endpoints + location /api { + proxy_pass http://websocket/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # React app location / { - try_files \$uri \$uri/ /index.html; + try_files $uri $uri/ /index.html; } # Cache static assets diff --git a/docker-compose.yml b/docker-compose.yml index 5f59a45..3f684b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,26 @@ services: + # WebSocket backend server + boredom-ws: + build: ./server + container_name: boredom-ws + restart: unless-stopped + environment: + - PORT=3001 + networks: + - boredom-internal + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 5s + retries: 3 + + # Frontend (nginx + React) boredom-dial: build: . container_name: boredom-dial-prod restart: unless-stopped + depends_on: + - boredom-ws labels: - "traefik.enable=true" - "traefik.http.routers.boredom-dial.rule=Host(`bored.jeffemmett.com`)" @@ -10,7 +28,10 @@ services: - "traefik.http.services.boredom-dial.loadbalancer.server.port=80" networks: - traefik-public + - boredom-internal networks: traefik-public: external: true + boredom-internal: + driver: bridge diff --git a/package-lock.json b/package-lock.json index 133c517..ab1992e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,15 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", - "firebase": "^11.9.1", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router-dom": "^7.1.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, "devDependencies": { + "@tailwindcss/postcss": "^4.1.17", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", "tailwindcss": "^4.1.10" @@ -2461,692 +2463,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@firebase/ai": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.0.tgz", - "integrity": "sha512-wvF33gtU6TXb6Co8TEC1pcl4dnVstYmRE/vs9XjUGE7he7Sgf5TqSu+EoXk/fuzhw5tKr1LC5eG9KdYFM+eosw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/analytics": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz", - "integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.22.tgz", - "integrity": "sha512-VogWHgwkdYhjWKh8O1XU04uPrRaiDihkWvE/EMMmtWtaUtVALnpLnUurc3QtSKdPnvTz5uaIGKlW84DGtSPFbw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/analytics": "0.10.16", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.1.tgz", - "integrity": "sha512-0O33PKrXLoIWkoOO5ByFaLjZehBctSYWnb+xJkIdx2SKP/K9l1UPFXPwASyrOIqyY3ws+7orF/1j7wI5EKzPYQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-check": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.0.tgz", - "integrity": "sha512-AZlRlVWKcu8BH4Yf8B5EI8sOi2UNGTS8oMuthV45tbt6OVUTSQwFPIEboZzhNJNKY+fPsg7hH8vixUWFZ3lrhw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/app-check-compat": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.25.tgz", - "integrity": "sha512-3zrsPZWAKfV7DVC20T2dgfjzjtQnSJS65OfMOiddMUtJL1S5i0nAZKsdX0bOEvvrd0SBIL8jYnfpfDeQRnhV3w==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check": "0.10.0", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-compat": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.1.tgz", - "integrity": "sha512-9VGjnY23Gc1XryoF/ABWtZVJYnaPOnjHM7dsqq9YALgKRtxI1FryvELUVkDaEIUf4In2bfkb9ZENF1S9M273Dw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app": "0.13.1", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.7.tgz", - "integrity": "sha512-77o0aBKCfchdL1gkahARdawHyYefh+wRYn7o60tbwW6bfJNq2idbrRb3WSYCT4yBKWL0+9kKdwxBHPZ6DEiB+g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^1.18.1" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@firebase/auth-compat": { - "version": "0.5.27", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.27.tgz", - "integrity": "sha512-axZx/MgjNO7uPA8/nMQiuVotGCngUFMppt5w0pxFIoIPD0kac0bsFdSEh5S2ttuEE0Aq1iUB6Flzwn+wvMgXnQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth": "1.10.7", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/component": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz", - "integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/data-connect": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.9.tgz", - "integrity": "sha512-B5tGEh5uQrQeH0i7RvlU8kbZrKOJUmoyxVIX4zLA8qQJIN6A7D+kfBlGXtSwbPdrvyaejcRPcbOtqsDQ9HPJKw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/database": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.19.tgz", - "integrity": "sha512-khE+MIYK+XlIndVn/7mAQ9F1fwG5JHrGKaG72hblCC6JAlUBDd3SirICH6SMCf2PQ0iYkruTECth+cRhauacyQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-compat": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.10.tgz", - "integrity": "sha512-3sjl6oGaDDYJw/Ny0E5bO6v+KM3KoD4Qo/sAfHGdRFmcJ4QnfxOX9RbG9+ce/evI3m64mkPr24LlmTDduqMpog==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/database": "1.0.19", - "@firebase/database-types": "1.0.14", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-types": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.14.tgz", - "integrity": "sha512-8a0Q1GrxM0akgF0RiQHliinhmZd+UQPrxEmUv7MnQBYfVFiLtKOgs3g6ghRt/WEGJHyQNslZ+0PocIwNfoDwKw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.12.0" - } - }, - "node_modules/@firebase/firestore": { - "version": "4.7.17", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.17.tgz", - "integrity": "sha512-YhXWA7HlSnekExhZ5u4i0e+kpPxsh/qMrzeNDgsAva71JXK8OOuOx+yLyYBFhmu3Hr5JJDO2fsZA/wrWoQYHDg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "@firebase/webchannel-wrapper": "1.0.3", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/firestore-compat": { - "version": "0.3.52", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.52.tgz", - "integrity": "sha512-nzt3Sag+EBdm1Jkw/FnnKBPk0LpUUxOlMHMADPBXYhhXrLszxn1+vb64nJsbgRIHfsCn+rg8gyGrb+8frzXrjg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/firestore": "4.7.17", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/functions": { - "version": "0.12.8", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.8.tgz", - "integrity": "sha512-p+ft6dQW0CJ3BLLxeDb5Hwk9ARw01kHTZjLqiUdPRzycR6w7Z75ThkegNmL6gCss3S0JEpldgvehgZ3kHybVhA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/functions-compat": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.25.tgz", - "integrity": "sha512-V0JKUw5W/7aznXf9BQ8LIYHCX6zVCM8Hdw7XUQ/LU1Y9TVP8WKRCnPB/qdPJ0xGjWWn7fhtwIYbgEw/syH4yTQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/functions": "0.12.8", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/installations": { - "version": "0.6.17", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz", - "integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/installations-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.17.tgz", - "integrity": "sha512-J7afeCXB7yq25FrrJAgbx8mn1nG1lZEubOLvYgG7ZHvyoOCK00sis5rj7TgDrLYJgdj/SJiGaO1BD3BAp55TeA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/logger": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", - "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/messaging": { - "version": "0.12.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz", - "integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/messaging-compat": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.21.tgz", - "integrity": "sha512-1yMne+4BGLbHbtyu/VyXWcLiefUE1+K3ZGfVTyKM4BH4ZwDFRGoWUGhhx+tKRX4Tu9z7+8JN67SjnwacyNWK5g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/messaging": "0.12.21", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/performance": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.6.tgz", - "integrity": "sha512-AsOz74dSTlyQGlnnbLWXiHFAsrxhpssPOsFFi4HgOJ5DjzkK7ZdZ/E9uMPrwFoXJyMVoybGRuqsL/wkIbFITsA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/performance-compat": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.19.tgz", - "integrity": "sha512-4cU0T0BJ+LZK/E/UwFcvpBCVdkStgBMQwBztM9fJPT6udrEUk3ugF5/HT+E2Z22FCXtIaXDukJbYkE/c3c6IHw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/performance": "0.7.6", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/performance/node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/remote-config": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.4.tgz", - "integrity": "sha512-ZyLJRT46wtycyz2+opEkGaoFUOqRQjt/0NX1WfUISOMCI/PuVoyDjqGpq24uK+e8D5NknyTpiXCVq5dowhScmg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/installations": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/remote-config-compat": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.17.tgz", - "integrity": "sha512-KelsBD0sXSC0u3esr/r6sJYGRN6pzn3bYuI/6pTvvmZbjBlxQkRabHAVH6d+YhLcjUXKIAYIjZszczd1QJtOyA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/logger": "0.4.4", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-types": "0.4.0", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/remote-config-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", - "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/storage": { - "version": "0.13.13", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.13.tgz", - "integrity": "sha512-E+MTNcBgpoAynicgVb2ZsHCuEOO4aAiUX5ahNwe/1dEyZpo2H4DwFqKQRNK/sdAIgBbjBwcfV2p0MdPFGIR0Ew==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/storage-compat": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.23.tgz", - "integrity": "sha512-B/ufkT/R/tSvc2av+vP6ZYybGn26FwB9YVDYg/6Bro+5TN3VEkCeNmfnX3XLa2DSdXUTZAdWCbMxW0povGa4MA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.6.17", - "@firebase/storage": "0.13.13", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.12.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/util": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz", - "integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", - "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", - "license": "Apache-2.0" - }, - "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" - }, - "engines": { - "node": "^8.13.0 || >=10.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@grpc/proto-loader/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@grpc/proto-loader/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3605,6 +2921,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -3634,10 +2960,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -3779,70 +3104,6 @@ } } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -4191,6 +3452,280 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/node/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -7327,6 +6862,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -7638,10 +7182,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "license": "MIT", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -8930,42 +8473,6 @@ "node": ">=8" } }, - "node_modules/firebase": { - "version": "11.9.1", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.9.1.tgz", - "integrity": "sha512-nbQbQxNlkHHRDn4cYwHdAKHwJPeZ0jRXxlNp6PCOb9CQx8Dc6Vjve97R34r1EZJnzOsPYZ3+ssJH7fkovDjvCw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/ai": "1.4.0", - "@firebase/analytics": "0.10.16", - "@firebase/analytics-compat": "0.2.22", - "@firebase/app": "0.13.1", - "@firebase/app-check": "0.10.0", - "@firebase/app-check-compat": "0.3.25", - "@firebase/app-compat": "0.4.1", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.10.7", - "@firebase/auth-compat": "0.5.27", - "@firebase/data-connect": "0.3.9", - "@firebase/database": "1.0.19", - "@firebase/database-compat": "2.0.10", - "@firebase/firestore": "4.7.17", - "@firebase/firestore-compat": "0.3.52", - "@firebase/functions": "0.12.8", - "@firebase/functions-compat": "0.3.25", - "@firebase/installations": "0.6.17", - "@firebase/installations-compat": "0.2.17", - "@firebase/messaging": "0.12.21", - "@firebase/messaging-compat": "0.2.21", - "@firebase/performance": "0.7.6", - "@firebase/performance-compat": "0.2.19", - "@firebase/remote-config": "0.6.4", - "@firebase/remote-config-compat": "0.2.17", - "@firebase/storage": "0.13.13", - "@firebase/storage-compat": "0.3.23", - "@firebase/util": "1.12.0" - } - }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -11910,6 +11417,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -11966,12 +11722,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12002,12 +11752,6 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14425,30 +14169,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -14503,6 +14223,14 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -14762,6 +14490,54 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "dependencies": { + "react-router": "7.10.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -15717,6 +15493,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16750,11 +16531,10 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", - "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", - "dev": true, - "license": "MIT" + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true }, "node_modules/tapable": { "version": "2.2.2", diff --git a/package.json b/package.json index 7780e19..37064c9 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", - "firebase": "^11.9.1", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router-dom": "^7.1.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, @@ -38,6 +39,7 @@ ] }, "devDependencies": { + "@tailwindcss/postcss": "^4.1.17", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", "tailwindcss": "^4.1.10" diff --git a/postcss.config.js b/postcss.config.js index 33ad091..668a5b9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..7cab5b5 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + + 🥱 + diff --git a/public/index.html b/public/index.html index aa069f2..b04e296 100644 --- a/public/index.html +++ b/public/index.html @@ -2,42 +2,28 @@ - - - - + + + + + + + - - - React App + + + + Collective Boredom Dial +
- diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..138aaf6 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY index.js . + +EXPOSE 3001 + +CMD ["node", "index.js"] diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..f4ccb75 --- /dev/null +++ b/server/index.js @@ -0,0 +1,299 @@ +const WebSocket = require('ws'); +const http = require('http'); +const crypto = require('crypto'); + +const PORT = process.env.PORT || 3001; + +// Room storage: { roomId: { users: Map, createdAt: Date, name: string } } +const rooms = new Map(); + +// Global room (the default public room with bots) +const GLOBAL_ROOM_ID = 'global'; + +// Generate short room codes +const generateRoomCode = () => { + return crypto.randomBytes(3).toString('hex').toUpperCase(); +}; + +// Generate user ID +const generateUserId = () => crypto.randomBytes(8).toString('hex'); + +// Simulated users for global room +const bots = [ + { id: 'bot-restless', name: 'Restless Rita', boredom: 65, volatility: 15, speed: 3000 }, + { id: 'bot-chill', name: 'Chill Charlie', boredom: 25, volatility: 8, speed: 7000 }, + { id: 'bot-moody', name: 'Moody Morgan', boredom: 50, volatility: 25, speed: 4000 }, + { id: 'bot-sleepy', name: 'Sleepy Sam', boredom: 80, volatility: 10, speed: 10000 }, +]; + +// Initialize global room with bots +const globalRoom = { + users: new Map(), + createdAt: new Date(), + name: 'Global Boredom', + isGlobal: true +}; + +bots.forEach(bot => { + globalRoom.users.set(bot.id, { + boredom: bot.boredom, + ws: null, + isBot: true, + name: bot.name + }); +}); + +rooms.set(GLOBAL_ROOM_ID, globalRoom); + +// Start bot simulation for global room +bots.forEach(bot => { + setInterval(() => { + const room = rooms.get(GLOBAL_ROOM_ID); + if (!room) return; + + const user = room.users.get(bot.id); + if (!user) return; + + const drift = (bot.boredom - user.boredom) * 0.1; + const randomChange = (Math.random() - 0.5) * bot.volatility; + user.boredom = Math.max(0, Math.min(100, user.boredom + drift + randomChange)); + + broadcastToRoom(GLOBAL_ROOM_ID); + }, bot.speed); +}); + +// Get room stats +const getRoomStats = (roomId) => { + const room = rooms.get(roomId); + if (!room) return null; + + const entries = Array.from(room.users.entries()); + const values = entries.map(([_, u]) => u.boredom); + const count = values.length; + const average = count > 0 + ? Math.round(values.reduce((a, b) => a + b, 0) / count) + : 50; + + const individuals = entries.map(([id, u]) => ({ + id, + boredom: Math.round(u.boredom), + isBot: u.isBot || false, + name: u.name || null + })); + + return { + average, + count, + individuals, + roomName: room.name, + roomId + }; +}; + +// Broadcast to all users in a room +const broadcastToRoom = (roomId) => { + const room = rooms.get(roomId); + if (!room) return; + + const stats = getRoomStats(roomId); + const message = JSON.stringify({ + type: 'stats', + ...stats + }); + + room.users.forEach((user) => { + if (user.ws && user.ws.readyState === WebSocket.OPEN) { + user.ws.send(message); + } + }); +}; + +// Clean up old empty rooms (except global) +setInterval(() => { + const now = Date.now(); + rooms.forEach((room, roomId) => { + if (roomId === GLOBAL_ROOM_ID) return; + + // Count real users (with websocket connections) + const realUsers = Array.from(room.users.values()).filter(u => u.ws); + + // Remove room if empty for more than 1 hour + if (realUsers.length === 0 && now - room.createdAt.getTime() > 3600000) { + rooms.delete(roomId); + console.log(`Cleaned up empty room: ${roomId}`); + } + }); +}, 60000); + +// HTTP server for health checks and room creation +const server = http.createServer((req, res) => { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + if (req.url === '/health') { + const stats = getRoomStats(GLOBAL_ROOM_ID); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + rooms: rooms.size, + globalUsers: stats?.count || 0 + })); + return; + } + + if (req.url === '/api/rooms' && req.method === 'POST') { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const data = JSON.parse(body || '{}'); + const roomId = generateRoomCode(); + const roomName = data.name || `Room ${roomId}`; + + rooms.set(roomId, { + users: new Map(), + createdAt: new Date(), + name: roomName, + isGlobal: false + }); + + console.log(`Created room: ${roomId} - ${roomName}`); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ roomId, roomName })); + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request' })); + } + }); + return; + } + + if (req.url.startsWith('/api/rooms/') && req.method === 'GET') { + const roomId = req.url.split('/')[3]; + const room = rooms.get(roomId); + + if (room) { + const stats = getRoomStats(roomId); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(stats)); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Room not found' })); + } + return; + } + + res.writeHead(404); + res.end(); +}); + +// WebSocket server +const wss = new WebSocket.Server({ server }); + +wss.on('connection', (ws, req) => { + // Extract room ID from URL query parameter + const url = new URL(req.url, `http://${req.headers.host}`); + let roomId = url.searchParams.get('room') || GLOBAL_ROOM_ID; + + // Validate room exists + if (!rooms.has(roomId)) { + // Create room if it looks like a valid code + if (roomId.length === 6 && /^[A-Z0-9]+$/.test(roomId)) { + rooms.set(roomId, { + users: new Map(), + createdAt: new Date(), + name: `Room ${roomId}`, + isGlobal: false + }); + } else { + roomId = GLOBAL_ROOM_ID; + } + } + + const room = rooms.get(roomId); + const userId = generateUserId(); + + // Get name from query or generate + const userName = url.searchParams.get('name') || null; + + // Add user to room + room.users.set(userId, { + boredom: 50, + ws, + isBot: false, + name: userName + }); + + console.log(`User ${userId} joined room ${roomId}. Users in room: ${room.users.size}`); + + // Send welcome message + const stats = getRoomStats(roomId); + ws.send(JSON.stringify({ + type: 'welcome', + userId, + roomId, + roomName: room.name, + boredom: 50, + ...stats + })); + + // Broadcast updated stats + broadcastToRoom(roomId); + + // Handle messages + ws.on('message', (data) => { + try { + const message = JSON.parse(data); + + if (message.type === 'update' && typeof message.boredom === 'number') { + const boredom = Math.max(0, Math.min(100, Math.round(message.boredom))); + const user = room.users.get(userId); + if (user) { + user.boredom = boredom; + broadcastToRoom(roomId); + } + } + + if (message.type === 'setName' && message.name) { + const user = room.users.get(userId); + if (user) { + user.name = message.name.slice(0, 20); + broadcastToRoom(roomId); + } + } + } catch (err) { + console.error('Invalid message:', err.message); + } + }); + + // Handle disconnect + ws.on('close', () => { + room.users.delete(userId); + console.log(`User ${userId} left room ${roomId}. Users in room: ${room.users.size}`); + broadcastToRoom(roomId); + }); + + ws.on('error', (err) => { + console.error(`WebSocket error for ${userId}:`, err.message); + }); +}); + +server.listen(PORT, () => { + console.log(`Boredom Dial server running on port ${PORT}`); + console.log(`Global room initialized with ${bots.length} bots`); +}); + +process.on('SIGTERM', () => { + console.log('Shutting down...'); + wss.clients.forEach((client) => client.close()); + server.close(() => process.exit(0)); +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..21b510f --- /dev/null +++ b/server/package.json @@ -0,0 +1,12 @@ +{ + "name": "boredom-dial-server", + "version": "1.0.0", + "description": "WebSocket server for Collective Boredom Dial", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/src/App.css b/src/App.css index 74b5e05..1b4f599 100644 --- a/src/App.css +++ b/src/App.css @@ -1,38 +1,699 @@ -.App { +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); + min-height: 100vh; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; + color: #ffffff; + padding: 1rem; + max-width: 1200px; + margin: 0 auto; +} + +.app-header { text-align: center; + padding: 1.5rem 1rem 1rem; } -.App-logo { - height: 40vmin; - pointer-events: none; +.app-header h1 { + font-size: clamp(1.4rem, 4vw, 2.2rem); + font-weight: 700; + background: linear-gradient(135deg, #a855f7, #6366f1); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.25rem; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; +.subtitle { + font-size: 0.9rem; + color: #a1a1aa; +} + +/* Main content area */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Main dials row - side by side */ +.dials-row { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + padding: 1rem 0; +} + +@media (min-width: 700px) { + .dials-row { + gap: 2rem; + flex-wrap: nowrap; } } -.App-header { - background-color: #282c34; - min-height: 100vh; +.dial-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.dial-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.dial-label { + font-size: 0.9rem; + font-weight: 600; + color: #e4e4e7; + margin-top: 0.5rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.dial-hint { + font-size: 0.75rem; + color: #71717a; + text-align: center; +} + +/* Connector between dials */ +.dial-connector { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; + padding: 0 0.5rem; +} + +@media (max-width: 699px) { + .dial-connector { + flex-direction: column; + padding: 0.5rem 0; + } +} + +.connector-line { + width: 30px; + height: 2px; + background: linear-gradient(90deg, transparent, #6366f1, transparent); + opacity: 0.5; +} + +@media (max-width: 699px) { + .connector-line { + width: 2px; + height: 20px; + background: linear-gradient(180deg, transparent, #6366f1, transparent); + } +} + +.contribution-badge { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 1rem; + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 0.75rem; +} + +.contribution-value { + font-size: 1.25rem; + font-weight: 700; + color: #a855f7; +} + +.contribution-label { + font-size: 0.65rem; + color: #a1a1aa; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.user-count-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.35rem 0.75rem; + background: rgba(139, 92, 246, 0.15); + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 1rem; + font-size: 0.8rem; + color: #c4b5fd; +} + +/* Participants section */ +.participants-section { + padding: 1rem; + background: rgba(255, 255, 255, 0.02); + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.participants-title { + font-size: 0.85rem; + font-weight: 600; + color: #a1a1aa; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 1rem; + text-align: center; +} + +.participants-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; +} + +/* Mini dial styles */ +.mini-dial { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all 0.2s ease; +} + +.mini-dial:hover { + background: rgba(255, 255, 255, 0.05); +} + +.mini-dial.is-you { + background: rgba(99, 102, 241, 0.1); + border-color: rgba(99, 102, 241, 0.3); +} + +.mini-dial.is-bot { + opacity: 0.85; +} + +.mini-dial svg { + display: block; +} + +.mini-dial-label { + font-size: 0.7rem; + color: #a1a1aa; + margin-top: 0.25rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.mini-dial.is-you .mini-dial-label { + color: #c4b5fd; + font-weight: 600; +} + +.bot-badge { + font-size: 0.55rem; + padding: 0.1rem 0.3rem; + background: rgba(139, 92, 246, 0.2); + border-radius: 0.25rem; + color: #a78bfa; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Footer */ +.app-footer { + text-align: center; + padding: 1rem; + margin-top: auto; +} + +.status-message { + font-size: 0.7rem; + color: #22c55e; + margin-bottom: 0.25rem; +} + +.status-message.error { + color: #f97316; +} + +.credits { + font-size: 0.75rem; + color: #52525b; + font-style: italic; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dial-section { + animation: fadeIn 0.5s ease-out forwards; +} + +.dial-section.individual { + animation-delay: 0.1s; +} + +.dial-section.global { + animation-delay: 0.2s; +} + +.participants-section { + animation: fadeIn 0.5s ease-out forwards; + animation-delay: 0.3s; +} + +/* SVG interaction */ +.dial-section.individual svg { + cursor: pointer; +} + +.dial-section.individual svg:hover { + filter: drop-shadow(0 0 15px rgba(99, 102, 241, 0.3)); +} + +.dial-section.global svg { + filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.15)); +} + +/* Touch feedback */ +@media (hover: none) { + .dial-section.individual svg:active { + transform: scale(0.98); + } +} + +/* Responsive adjustments */ +@media (max-width: 500px) { + .dial-section.individual .dial-container svg { + width: 200px; + height: 200px; + } + + .dial-section.global .dial-container svg { + width: 220px; + height: 220px; + } + + .mini-dial svg { + width: 70px; + height: 70px; + } +} + +/* ==================== HOME PAGE ==================== */ +.home-content { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - font-size: calc(10px + 2vmin); + padding: 2rem 1rem; +} + +.home-options { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; + max-width: 400px; +} + +.home-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1rem; + padding: 1.5rem; + text-align: center; +} + +.home-card h2 { + font-size: 1.1rem; + font-weight: 600; + color: #e4e4e7; + margin-bottom: 0.5rem; +} + +.home-card p { + font-size: 0.85rem; + color: #71717a; + margin-bottom: 1rem; +} + +.home-divider { + display: flex; + align-items: center; + gap: 1rem; +} + +.home-divider::before, +.home-divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.1); +} + +.home-divider span { + font-size: 0.75rem; + color: #52525b; + text-transform: uppercase; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: linear-gradient(135deg, #6366f1, #a855f7); color: white; } -.App-link { - color: #61dafb; +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4); } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: #e4e4e7; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1rem; +} + +/* Input fields */ +.input-field { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 0.5rem; + background: rgba(0, 0, 0, 0.3); + color: #ffffff; + font-size: 0.9rem; + margin-bottom: 0.75rem; + outline: none; + transition: border-color 0.2s ease; +} + +.input-field:focus { + border-color: #6366f1; +} + +.input-field::placeholder { + color: #52525b; +} + +.room-code-input { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 1.2rem; + text-align: center; + letter-spacing: 0.3em; +} + +.error-message { + color: #f97316; + font-size: 0.85rem; + margin-top: 1rem; + text-align: center; +} + +/* ==================== ROOM HEADER ==================== */ +.app-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.header-content { + flex: 1; + text-align: center; +} + +.back-btn, +.share-btn { + position: absolute; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 0.5rem; + color: #a1a1aa; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.back-btn { + left: 0; +} + +.share-btn { + right: 0; +} + +.back-btn:hover, +.share-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #e4e4e7; +} + +.room-code-display { + font-size: 0.8rem; + color: #a78bfa; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + letter-spacing: 0.1em; +} + +/* ==================== SHARE MODAL ==================== */ +.share-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: fadeIn 0.2s ease-out; +} + +.share-content { + background: #1e1e2e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1rem; + padding: 2rem; + max-width: 350px; + width: 100%; + text-align: center; +} + +.share-content h3 { + font-size: 1.2rem; + font-weight: 600; + color: #e4e4e7; + margin-bottom: 1.5rem; +} + +.qr-container { + background: #1e1e2e; + padding: 1rem; + border-radius: 0.75rem; + display: inline-block; + margin-bottom: 1rem; +} + +.share-code { + font-size: 1rem; + color: #a1a1aa; + margin-bottom: 1rem; +} + +.share-code strong { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + color: #a78bfa; + font-size: 1.2rem; + letter-spacing: 0.2em; +} + +.share-url { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.share-url input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 0.375rem; + background: rgba(0, 0, 0, 0.3); + color: #a1a1aa; + font-size: 0.75rem; + outline: none; +} + +.share-url button { + padding: 0.5rem 1rem; + background: #6366f1; + border: none; + border-radius: 0.375rem; + color: white; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; +} + +.share-url button:hover { + background: #4f46e5; +} + +.close-btn { + margin-top: 0.5rem; +} + +/* ==================== MOBILE VIEW ==================== */ +.mobile-app { + padding: 0.5rem; +} + +.app-header.compact { + padding: 1rem 0.5rem; +} + +.app-header.compact h1 { + font-size: 1.2rem; +} + +.user-count-inline { + font-size: 0.75rem; + color: #a1a1aa; +} + +.join-content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; +} + +.join-form { + width: 100%; + max-width: 300px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mobile-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2rem; + padding: 1rem; +} + +.mobile-dial-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.mobile-collective { + width: 100%; + max-width: 280px; +} + +.collective-preview { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: rgba(139, 92, 246, 0.1); + border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 1rem; +} + +.collective-label { + font-size: 0.9rem; + color: #a1a1aa; +} + +.collective-value { + font-size: 2rem; + font-weight: 700; + color: #a78bfa; +} + +/* Mobile-specific touch optimizations */ +@media (max-width: 500px) { + .mobile-dial-section .dial-container svg { + width: 260px; + height: 260px; + } +} + +@media (hover: none) and (pointer: coarse) { + .btn { + padding: 1rem 1.5rem; + } + + .input-field { + padding: 1rem; + font-size: 1rem; } } diff --git a/src/App.js b/src/App.js index 3784575..17dd151 100644 --- a/src/App.js +++ b/src/App.js @@ -1,25 +1,571 @@ -import logo from './logo.svg'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { BrowserRouter, Routes, Route, useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { QRCodeSVG } from 'qrcode.react'; +import Dial from './components/Dial'; import './App.css'; -function App() { +// Same colors as in Dial.js - keep in sync! +const USER_COLORS = [ + '#6366f1', // indigo (you) + '#f472b6', // pink + '#22d3ee', // cyan + '#a78bfa', // purple + '#fb923c', // orange + '#4ade80', // green + '#fbbf24', // amber + '#f87171', // red + '#2dd4bf', // teal + '#c084fc', // violet +]; + +// WebSocket connection hook +const useWebSocket = (roomId = 'global', userName = null) => { + const [isConnected, setIsConnected] = useState(false); + const [userId, setUserId] = useState(null); + const [roomName, setRoomName] = useState(''); + const [globalBoredom, setGlobalBoredom] = useState(50); + const [userCount, setUserCount] = useState(0); + const [individuals, setIndividuals] = useState([]); + const [error, setError] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + const connect = useCallback(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let wsUrl = `${protocol}//${window.location.host}/ws?room=${roomId}`; + if (userName) { + wsUrl += `&name=${encodeURIComponent(userName)}`; + } + + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setIsConnected(true); + setError(null); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'welcome') { + setUserId(data.userId); + setRoomName(data.roomName || ''); + setGlobalBoredom(data.average || 50); + setUserCount(data.count || 0); + setIndividuals(data.individuals || []); + } else if (data.type === 'stats') { + setGlobalBoredom(data.average || 50); + setUserCount(data.count || 0); + setIndividuals(data.individuals || []); + } + } catch (err) { + console.error('Failed to parse message:', err); + } + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, 2000); + }; + + ws.onerror = () => { + setError('Connection error'); + }; + } catch (err) { + setError('Failed to connect'); + } + }, [roomId, userName]); + + useEffect(() => { + connect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, [connect]); + + const sendBoredom = useCallback((value) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'update', + boredom: value + })); + } + }, []); + + const sendName = useCallback((name) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'setName', + name: name + })); + } + }, []); + + return { + isConnected, + userId, + roomName, + globalBoredom, + userCount, + individuals, + error, + sendBoredom, + sendName + }; +}; + +// Mini dial with user-specific color +const MiniDial = ({ value, label, isYou, isBot, userColor }) => { + const size = 80; + const center = size / 2; + const radius = size * 0.35; + const strokeWidth = size * 0.1; + + const valueToAngle = (val) => -135 + (val / 100) * 270; + + const getPointOnCircle = (angle, r = radius) => { + const radians = (angle - 90) * (Math.PI / 180); + return { + x: center + r * Math.cos(radians), + y: center + r * Math.sin(radians) + }; + }; + + const createArc = (startAngle, endAngle, r = radius) => { + const start = getPointOnCircle(startAngle, r); + const end = getPointOnCircle(endAngle, r); + const largeArc = endAngle - startAngle > 180 ? 1 : 0; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; + }; + + const currentAngle = valueToAngle(value); + return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- + + + {value > 0 && ( + + )} + - Learn React - + {Math.round(value)} + + +
+ {isYou ? 'You' : label || 'User'} + {isBot && bot} +
+
+ ); +}; + +// Home page - create or join room +function HomePage() { + const navigate = useNavigate(); + const [roomCode, setRoomCode] = useState(''); + const [roomName, setRoomName] = useState(''); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(''); + + const createRoom = async () => { + setCreating(true); + setError(''); + try { + const res = await fetch('/api/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: roomName || undefined }) + }); + const data = await res.json(); + if (data.roomId) { + navigate(`/room/${data.roomId}`); + } else { + setError('Failed to create room'); + } + } catch (err) { + setError('Failed to create room'); + } + setCreating(false); + }; + + const joinRoom = () => { + const code = roomCode.trim().toUpperCase(); + if (code.length === 6) { + navigate(`/room/${code}`); + } else { + setError('Room code must be 6 characters'); + } + }; + + return ( +
+
+

Collective Boredom Dial

+

How bored are we, really?

+ +
+
+
+

Join Global Room

+

See how the world feels right now

+ +
+ +
+ or +
+ +
+

Create Private Room

+

Start a boredom session with your group

+ setRoomName(e.target.value)} + className="input-field" + /> + +
+ +
+ or +
+ +
+

Join Private Room

+

Enter a 6-character room code

+ setRoomCode(e.target.value.toUpperCase().slice(0, 6))} + className="input-field room-code-input" + maxLength={6} + /> + +
+
+ + {error &&

{error}

} +
+ +
+

A collective experiment in quantifying ennui

+
); } +// Room view - full dial experience +function RoomPage() { + const { roomId } = useParams(); + const navigate = useNavigate(); + const [myBoredom, setMyBoredom] = useState(50); + const [showShare, setShowShare] = useState(false); + const { isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom } = useWebSocket(roomId); + + const handleBoredomChange = useCallback((value) => { + setMyBoredom(value); + sendBoredom(value); + }, [sendBoredom]); + + const myContribution = userCount > 0 ? 100 / userCount : 100; + + // Build segments with live local value for self + const segments = individuals.map(ind => ({ + ...ind, + boredom: ind.id === userId ? myBoredom : ind.boredom + })); + + // Sort for consistent color assignment (same as in Dial.js) + const sortedForColors = [...segments].sort((a, b) => { + if (a.id === userId) return -1; + if (b.id === userId) return 1; + return a.id.localeCompare(b.id); + }); + + // Create color map + const colorMap = {}; + sortedForColors.forEach((seg, index) => { + colorMap[seg.id] = USER_COLORS[index % USER_COLORS.length]; + }); + + // Others for the mini-dial grid (excluding self) + const others = sortedForColors.filter(u => u.id !== userId); + + const shareUrl = `${window.location.origin}/join/${roomId}`; + const isGlobal = roomId === 'global'; + + const copyToClipboard = () => { + navigator.clipboard.writeText(shareUrl); + }; + + return ( +
+
+ +
+

{roomName || 'Collective Boredom Dial'}

+ {!isGlobal && ( +

Room: {roomId}

+ )} +
+ {!isGlobal && ( + + )} +
+ + {showShare && !isGlobal && ( +
setShowShare(false)}> +
e.stopPropagation()}> +

Share this room

+
+ +
+

Room Code: {roomId}

+
+ + +
+ +
+
+ )} + +
+
+
+ +

Drag to adjust

+
+ +
+
+
+ {myContribution.toFixed(0)}% + influence +
+
+
+ +
+ +
+ {userCount} {userCount === 1 ? 'person' : 'people'} +
+
+
+ +
+

Everyone's Boredom

+
+ + {others.map((user) => ( + + ))} +
+
+
+ +
+

+ {error || (isConnected ? 'Connected' : 'Connecting...')} +

+

A collective experiment in quantifying ennui

+
+
+ ); +} + +// Mobile participant view - simplified for quick input +function JoinPage() { + const { roomId } = useParams(); + const [searchParams] = useSearchParams(); + const [myBoredom, setMyBoredom] = useState(50); + const [name, setName] = useState(searchParams.get('name') || ''); + const [hasJoined, setHasJoined] = useState(false); + const { isConnected, userId, roomName, globalBoredom, userCount, error, sendBoredom, sendName } = useWebSocket(roomId, name || undefined); + + const handleJoin = () => { + if (name.trim()) { + sendName(name.trim()); + } + setHasJoined(true); + }; + + const handleBoredomChange = useCallback((value) => { + setMyBoredom(value); + sendBoredom(value); + }, [sendBoredom]); + + if (!hasJoined) { + return ( +
+
+

Join Boredom Room

+

{roomName || `Room ${roomId}`}

+
+ +
+
+ setName(e.target.value.slice(0, 20))} + className="input-field" + autoFocus + /> + +
+
+ +
+

+ {error || (isConnected ? 'Connected' : 'Connecting...')} +

+
+
+ ); + } + + return ( +
+
+

{roomName || 'Boredom Room'}

+

{userCount} {userCount === 1 ? 'person' : 'people'}

+
+ +
+
+ +

Drag to set your boredom level

+
+ +
+
+ Collective: + {Math.round(globalBoredom)} +
+
+
+ +
+

+ {error || (isConnected ? 'Connected' : 'Connecting...')} +

+
+
+ ); +} + +function App() { + return ( + + + } /> + } /> + } /> + + + ); +} + export default App; diff --git a/src/components/Dial.js b/src/components/Dial.js new file mode 100644 index 0000000..356863b --- /dev/null +++ b/src/components/Dial.js @@ -0,0 +1,331 @@ +import React, { useRef, useCallback, useEffect, useState } from 'react'; + +// Distinct colors for each user +const USER_COLORS = [ + '#6366f1', // indigo (you) + '#f472b6', // pink + '#22d3ee', // cyan + '#a78bfa', // purple + '#fb923c', // orange + '#4ade80', // green + '#fbbf24', // amber + '#f87171', // red + '#2dd4bf', // teal + '#c084fc', // violet +]; + +const Dial = ({ + value = 50, + onChange, + size = 280, + interactive = true, + label = 'Boredom', + color = '#6366f1', + trackColor = '#1e1e2e', + segments = null, + userId = null +}) => { + const svgRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const center = size / 2; + const radius = size * 0.38; + const strokeWidth = size * 0.08; + const knobRadius = size * 0.06; + + const ARC_DEGREES = 270; + const START_ANGLE = -135; + const END_ANGLE = 135; + + const valueToAngle = (val) => START_ANGLE + (val / 100) * ARC_DEGREES; + + const angleToValue = (angle) => { + let normalized = angle; + if (normalized < START_ANGLE) normalized = START_ANGLE; + if (normalized > END_ANGLE) normalized = END_ANGLE; + return Math.round(((normalized - START_ANGLE) / ARC_DEGREES) * 100); + }; + + const getPointOnCircle = (angle, r = radius) => { + const radians = (angle - 90) * (Math.PI / 180); + return { + x: center + r * Math.cos(radians), + y: center + r * Math.sin(radians) + }; + }; + + const createArc = (startAngle, endAngle, r = radius) => { + if (Math.abs(endAngle - startAngle) < 0.1) return ''; + const start = getPointOnCircle(startAngle, r); + const end = getPointOnCircle(endAngle, r); + const largeArc = Math.abs(endAngle - startAngle) > 180 ? 1 : 0; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; + }; + + const handleInteraction = useCallback((clientX, clientY) => { + if (!interactive || !onChange) return; + + const svg = svgRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const scaleX = size / rect.width; + const scaleY = size / rect.height; + const x = (clientX - rect.left) * scaleX - center; + const y = (clientY - rect.top) * scaleY - center; + + let angle = Math.atan2(y, x) * (180 / Math.PI) + 90; + + if (angle > 180) angle = angle - 360; + if (angle < -180) angle = angle + 360; + + if (angle > END_ANGLE) angle = END_ANGLE; + if (angle < START_ANGLE) angle = START_ANGLE; + + const newValue = angleToValue(angle); + onChange(newValue); + }, [interactive, onChange, center, size]); + + const handleMouseDown = (e) => { + if (!interactive) return; + e.preventDefault(); + setIsDragging(true); + handleInteraction(e.clientX, e.clientY); + }; + + const handleMouseMove = useCallback((e) => { + if (!isDragging) return; + handleInteraction(e.clientX, e.clientY); + }, [isDragging, handleInteraction]); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleTouchStart = (e) => { + if (!interactive) return; + e.preventDefault(); + setIsDragging(true); + const touch = e.touches[0]; + handleInteraction(touch.clientX, touch.clientY); + }; + + const handleTouchMove = useCallback((e) => { + if (!isDragging) return; + const touch = e.touches[0]; + handleInteraction(touch.clientX, touch.clientY); + }, [isDragging, handleInteraction]); + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchmove', handleTouchMove); + window.addEventListener('touchend', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove]); + + const currentAngle = valueToAngle(value); + const knobPosition = getPointOnCircle(currentAngle); + + const getBoredomLabel = (val) => { + if (val < 15) return 'Engaged'; + if (val < 30) return 'Content'; + if (val < 50) return 'Neutral'; + if (val < 70) return 'Restless'; + if (val < 85) return 'Bored'; + return 'Very Bored'; + }; + + const getBoredomColor = (val) => { + if (val < 30) return '#22c55e'; + if (val < 50) return '#84cc16'; + if (val < 70) return '#eab308'; + if (val < 85) return '#f97316'; + return '#ef4444'; + }; + + const dynamicColor = color === 'dynamic' ? getBoredomColor(value) : color; + + // Render segmented arc - fills to average value, segments show contribution + const renderSegments = () => { + if (!segments || segments.length === 0) return null; + + const totalBoredom = segments.reduce((sum, s) => sum + s.boredom, 0); + if (totalBoredom === 0) return null; + + // Calculate average and the arc degrees it fills + const average = totalBoredom / segments.length; + const filledDegrees = (average / 100) * ARC_DEGREES; + + const segmentElements = []; + let currentAngle = START_ANGLE; + + // Sort: "You" first, then others by ID + const sorted = [...segments].sort((a, b) => { + if (a.id === userId) return -1; + if (b.id === userId) return 1; + return a.id.localeCompare(b.id); + }); + + sorted.forEach((segment, index) => { + // Each segment's size is proportional to their boredom within the filled area + const proportion = segment.boredom / totalBoredom; + const arcDegrees = proportion * filledDegrees; + const endAngle = currentAngle + arcDegrees; + + if (arcDegrees > 0.3) { + const segmentColor = USER_COLORS[index % USER_COLORS.length]; + const isYou = segment.id === userId; + + segmentElements.push( + + ); + + // Separator lines between segments + if (index < sorted.length - 1 && arcDegrees > 1.5) { + const innerPoint = getPointOnCircle(endAngle, radius - strokeWidth / 2); + const outerPoint = getPointOnCircle(endAngle, radius + strokeWidth / 2); + segmentElements.push( + + ); + } + } + + currentAngle = endAngle; + }); + + return segmentElements; + }; + + const centerColor = segments ? getBoredomColor(value) : dynamicColor; + + return ( +
+ + {/* Background track */} + + + {/* Segmented arc OR single value arc */} + {segments ? ( + renderSegments() + ) : ( + value > 0 && ( + + ) + )} + + {/* Tick marks */} + {[0, 25, 50, 75, 100].map((tick) => { + const tickAngle = valueToAngle(tick); + const inner = getPointOnCircle(tickAngle, radius - strokeWidth / 2 - 8); + const outer = getPointOnCircle(tickAngle, radius - strokeWidth / 2 - 2); + return ( + + ); + })} + + {/* Interactive knob */} + {interactive && ( + + )} + + {/* Center text */} + + {Math.round(value)} + + + + {getBoredomLabel(value)} + + + +
{label}
+
+ ); +}; + +export default Dial; diff --git a/src/index.css b/src/index.css index ec2585e..0d6e35f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,32 @@ -body { +* { + box-sizing: border-box; +} + +html, body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + padding: 0; + min-height: 100vh; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); + background-attachment: fixed; +} + +#root { + min-height: 100vh; +} + +/* Prevent text selection on dials */ +svg { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; }