feat: Add multiplayer room system with real-time consensus
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
eec7f473a8
commit
2b0c831399
39
Dockerfile
39
Dockerfile
|
|
@ -8,7 +8,7 @@ RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
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 rm -f postcss.config.js tailwind.config.js
|
||||||
RUN npm uninstall tailwindcss @tailwindcss/postcss 2>/dev/null || true
|
RUN npm uninstall tailwindcss @tailwindcss/postcss 2>/dev/null || true
|
||||||
|
|
||||||
|
|
@ -18,15 +18,48 @@ RUN npm run build
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
COPY --from=builder /app/build /usr/share/nginx/html
|
COPY --from=builder /app/build /usr/share/nginx/html
|
||||||
COPY <<EOF /etc/nginx/conf.d/default.conf
|
|
||||||
|
# Nginx config with WebSocket proxy
|
||||||
|
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
|
||||||
|
upstream websocket {
|
||||||
|
server boredom-ws:3001;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files \$uri \$uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,26 @@
|
||||||
services:
|
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:
|
boredom-dial:
|
||||||
build: .
|
build: .
|
||||||
container_name: boredom-dial-prod
|
container_name: boredom-dial-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- boredom-ws
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.boredom-dial.rule=Host(`bored.jeffemmett.com`)"
|
- "traefik.http.routers.boredom-dial.rule=Host(`bored.jeffemmett.com`)"
|
||||||
|
|
@ -10,7 +28,10 @@ services:
|
||||||
- "traefik.http.services.boredom-dial.loadbalancer.server.port=80"
|
- "traefik.http.services.boredom-dial.loadbalancer.server.port=80"
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
- boredom-internal
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
boredom-internal:
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,9 +7,10 @@
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"firebase": "^11.9.1",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.10"
|
"tailwindcss": "^4.1.10"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y="0.9em" font-size="90">🥱</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 115 B |
|
|
@ -2,42 +2,28 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" sizes="any" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta
|
<meta name="theme-color" content="#0f0f1a" />
|
||||||
name="description"
|
<meta name="description" content="A collective experiment in quantifying ennui. Set your boredom level and see how it blends into the global boredom." />
|
||||||
content="Web site created using create-react-app"
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
/>
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<!--
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
<title>Collective Boredom Dial</title>
|
||||||
|
<style>
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
body {
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
background: #0f0f1a;
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
margin: 0;
|
||||||
-->
|
}
|
||||||
<title>React App</title>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
699
src/App.css
699
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;
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-logo {
|
.app-header h1 {
|
||||||
height: 40vmin;
|
font-size: clamp(1.4rem, 4vw, 2.2rem);
|
||||||
pointer-events: none;
|
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) {
|
.subtitle {
|
||||||
.App-logo {
|
font-size: 0.9rem;
|
||||||
animation: App-logo-spin infinite 20s linear;
|
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 {
|
.dial-section {
|
||||||
background-color: #282c34;
|
display: flex;
|
||||||
min-height: 100vh;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-link {
|
.btn-primary:hover:not(:disabled) {
|
||||||
color: #61dafb;
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes App-logo-spin {
|
.btn-secondary {
|
||||||
from {
|
background: rgba(255, 255, 255, 0.08);
|
||||||
transform: rotate(0deg);
|
color: #e4e4e7;
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
to {
|
}
|
||||||
transform: rotate(360deg);
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
576
src/App.js
576
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';
|
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 (
|
return (
|
||||||
<div className="App">
|
<div className={`mini-dial ${isYou ? 'is-you' : ''} ${isBot ? 'is-bot' : ''}`}>
|
||||||
<header className="App-header">
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
<img src={logo} className="App-logo" alt="logo" />
|
<path
|
||||||
<p>
|
d={createArc(-135, 135)}
|
||||||
Edit <code>src/App.js</code> and save to reload.
|
fill="none"
|
||||||
</p>
|
stroke="#1e1e2e"
|
||||||
<a
|
strokeWidth={strokeWidth}
|
||||||
className="App-link"
|
strokeLinecap="round"
|
||||||
href="https://reactjs.org"
|
/>
|
||||||
target="_blank"
|
{value > 0 && (
|
||||||
rel="noopener noreferrer"
|
<path
|
||||||
|
d={createArc(-135, currentAngle)}
|
||||||
|
fill="none"
|
||||||
|
stroke={userColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<text
|
||||||
|
x={center}
|
||||||
|
y={center + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#ffffff"
|
||||||
|
fontSize={size * 0.22}
|
||||||
|
fontWeight="bold"
|
||||||
>
|
>
|
||||||
Learn React
|
{Math.round(value)}
|
||||||
</a>
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="mini-dial-label" style={{ color: userColor }}>
|
||||||
|
{isYou ? 'You' : label || 'User'}
|
||||||
|
{isBot && <span className="bot-badge">bot</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>Collective Boredom Dial</h1>
|
||||||
|
<p className="subtitle">How bored are we, really?</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main className="home-content">
|
||||||
|
<div className="home-options">
|
||||||
|
<div className="home-card">
|
||||||
|
<h2>Join Global Room</h2>
|
||||||
|
<p>See how the world feels right now</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/room/global')}>
|
||||||
|
Enter Global Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-card">
|
||||||
|
<h2>Create Private Room</h2>
|
||||||
|
<p>Start a boredom session with your group</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Room name (optional)"
|
||||||
|
value={roomName}
|
||||||
|
onChange={(e) => setRoomName(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={createRoom}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create Room'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-card">
|
||||||
|
<h2>Join Private Room</h2>
|
||||||
|
<p>Enter a 6-character room code</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ROOM CODE"
|
||||||
|
value={roomCode}
|
||||||
|
onChange={(e) => setRoomCode(e.target.value.toUpperCase().slice(0, 6))}
|
||||||
|
className="input-field room-code-input"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={joinRoom}
|
||||||
|
disabled={roomCode.length !== 6}
|
||||||
|
>
|
||||||
|
Join Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-message">{error}</p>}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p className="credits">A collective experiment in quantifying ennui</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<button className="back-btn" onClick={() => navigate('/')}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div className="header-content">
|
||||||
|
<h1>{roomName || 'Collective Boredom Dial'}</h1>
|
||||||
|
{!isGlobal && (
|
||||||
|
<p className="room-code-display">Room: {roomId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isGlobal && (
|
||||||
|
<button className="share-btn" onClick={() => setShowShare(!showShare)}>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{showShare && !isGlobal && (
|
||||||
|
<div className="share-modal" onClick={() => setShowShare(false)}>
|
||||||
|
<div className="share-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Share this room</h3>
|
||||||
|
<div className="qr-container">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={shareUrl}
|
||||||
|
size={200}
|
||||||
|
bgColor="#1e1e2e"
|
||||||
|
fgColor="#ffffff"
|
||||||
|
level="M"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="share-code">Room Code: <strong>{roomId}</strong></p>
|
||||||
|
<div className="share-url">
|
||||||
|
<input type="text" value={shareUrl} readOnly />
|
||||||
|
<button onClick={copyToClipboard}>Copy</button>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary close-btn" onClick={() => setShowShare(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
<div className="dials-row">
|
||||||
|
<div className="dial-section individual">
|
||||||
|
<Dial
|
||||||
|
value={myBoredom}
|
||||||
|
onChange={handleBoredomChange}
|
||||||
|
size={240}
|
||||||
|
interactive={true}
|
||||||
|
label="Your Boredom"
|
||||||
|
color="dynamic"
|
||||||
|
/>
|
||||||
|
<p className="dial-hint">Drag to adjust</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dial-connector">
|
||||||
|
<div className="connector-line"></div>
|
||||||
|
<div className="contribution-badge">
|
||||||
|
<span className="contribution-value">{myContribution.toFixed(0)}%</span>
|
||||||
|
<span className="contribution-label">influence</span>
|
||||||
|
</div>
|
||||||
|
<div className="connector-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dial-section global">
|
||||||
|
<Dial
|
||||||
|
value={globalBoredom}
|
||||||
|
size={280}
|
||||||
|
interactive={false}
|
||||||
|
label="Collective Boredom"
|
||||||
|
color="#8b5cf6"
|
||||||
|
segments={segments}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
<div className="user-count-badge">
|
||||||
|
{userCount} {userCount === 1 ? 'person' : 'people'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="participants-section">
|
||||||
|
<h2 className="participants-title">Everyone's Boredom</h2>
|
||||||
|
<div className="participants-grid">
|
||||||
|
<MiniDial
|
||||||
|
value={myBoredom}
|
||||||
|
label="You"
|
||||||
|
isYou={true}
|
||||||
|
isBot={false}
|
||||||
|
userColor={colorMap[userId] || USER_COLORS[0]}
|
||||||
|
/>
|
||||||
|
{others.map((user) => (
|
||||||
|
<MiniDial
|
||||||
|
key={user.id}
|
||||||
|
value={user.boredom}
|
||||||
|
label={user.name}
|
||||||
|
isYou={false}
|
||||||
|
isBot={user.isBot}
|
||||||
|
userColor={colorMap[user.id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p className={`status-message ${error ? 'error' : ''}`}>
|
||||||
|
{error || (isConnected ? 'Connected' : 'Connecting...')}
|
||||||
|
</p>
|
||||||
|
<p className="credits">A collective experiment in quantifying ennui</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="app mobile-app">
|
||||||
|
<header className="app-header compact">
|
||||||
|
<h1>Join Boredom Room</h1>
|
||||||
|
<p className="room-code-display">{roomName || `Room ${roomId}`}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="join-content">
|
||||||
|
<div className="join-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name (optional)"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value.slice(0, 20))}
|
||||||
|
className="input-field"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-large" onClick={handleJoin}>
|
||||||
|
Join Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p className={`status-message ${error ? 'error' : ''}`}>
|
||||||
|
{error || (isConnected ? 'Connected' : 'Connecting...')}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app mobile-app">
|
||||||
|
<header className="app-header compact">
|
||||||
|
<h1>{roomName || 'Boredom Room'}</h1>
|
||||||
|
<p className="user-count-inline">{userCount} {userCount === 1 ? 'person' : 'people'}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mobile-content">
|
||||||
|
<div className="mobile-dial-section">
|
||||||
|
<Dial
|
||||||
|
value={myBoredom}
|
||||||
|
onChange={handleBoredomChange}
|
||||||
|
size={260}
|
||||||
|
interactive={true}
|
||||||
|
label="Your Boredom"
|
||||||
|
color="dynamic"
|
||||||
|
/>
|
||||||
|
<p className="dial-hint">Drag to set your boredom level</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-collective">
|
||||||
|
<div className="collective-preview">
|
||||||
|
<span className="collective-label">Collective:</span>
|
||||||
|
<span className="collective-value">{Math.round(globalBoredom)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p className={`status-message ${error ? 'error' : ''}`}>
|
||||||
|
{error || (isConnected ? 'Connected' : 'Connecting...')}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||||
|
<Route path="/join/:roomId" element={<JoinPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
<path
|
||||||
|
key={segment.id}
|
||||||
|
d={createArc(currentAngle, endAngle)}
|
||||||
|
fill="none"
|
||||||
|
stroke={segmentColor}
|
||||||
|
strokeWidth={isYou ? strokeWidth * 1.15 : strokeWidth}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
opacity={isYou ? 1 : 0.9}
|
||||||
|
style={{
|
||||||
|
filter: isYou ? `drop-shadow(0 0 ${size * 0.02}px ${segmentColor})` : 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<line
|
||||||
|
key={`sep-${segment.id}`}
|
||||||
|
x1={innerPoint.x}
|
||||||
|
y1={innerPoint.y}
|
||||||
|
x2={outerPoint.x}
|
||||||
|
y2={outerPoint.y}
|
||||||
|
stroke="#0f0f1a"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAngle = endAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
return segmentElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const centerColor = segments ? getBoredomColor(value) : dynamicColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dial-container">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
style={{ cursor: interactive ? 'pointer' : 'default' }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
{/* Background track */}
|
||||||
|
<path
|
||||||
|
d={createArc(START_ANGLE, END_ANGLE)}
|
||||||
|
fill="none"
|
||||||
|
stroke={trackColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Segmented arc OR single value arc */}
|
||||||
|
{segments ? (
|
||||||
|
renderSegments()
|
||||||
|
) : (
|
||||||
|
value > 0 && (
|
||||||
|
<path
|
||||||
|
d={createArc(START_ANGLE, currentAngle)}
|
||||||
|
fill="none"
|
||||||
|
stroke={dynamicColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{
|
||||||
|
filter: `drop-shadow(0 0 ${size * 0.02}px ${dynamicColor})`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<line
|
||||||
|
key={tick}
|
||||||
|
x1={inner.x}
|
||||||
|
y1={inner.y}
|
||||||
|
x2={outer.x}
|
||||||
|
y2={outer.y}
|
||||||
|
stroke="#666"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Interactive knob */}
|
||||||
|
{interactive && (
|
||||||
|
<circle
|
||||||
|
cx={knobPosition.x}
|
||||||
|
cy={knobPosition.y}
|
||||||
|
r={knobRadius}
|
||||||
|
fill="#ffffff"
|
||||||
|
stroke={dynamicColor}
|
||||||
|
strokeWidth={3}
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Center text */}
|
||||||
|
<text
|
||||||
|
x={center}
|
||||||
|
y={center - size * 0.05}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={centerColor}
|
||||||
|
fontSize={size * 0.15}
|
||||||
|
fontWeight="bold"
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{Math.round(value)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x={center}
|
||||||
|
y={center + size * 0.08}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={centerColor}
|
||||||
|
fontSize={size * 0.055}
|
||||||
|
fontWeight="600"
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{getBoredomLabel(value)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="dial-label">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dial;
|
||||||
|
|
@ -1,13 +1,32 @@
|
||||||
body {
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
padding: 0;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
min-height: 100vh;
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
body {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
monospace;
|
'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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue