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 . .
|
||||
|
||||
# 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 <<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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
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">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" sizes="any" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f0f1a" />
|
||||
<meta name="description" content="A collective experiment in quantifying ennui. Set your boredom level and see how it blends into the global boredom." />
|
||||
<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" />
|
||||
<!--
|
||||
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" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Collective Boredom Dial</title>
|
||||
<style>
|
||||
body {
|
||||
background: #0f0f1a;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<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>
|
||||
</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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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';
|
||||
|
||||
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 (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div className={`mini-dial ${isYou ? 'is-you' : ''} ${isBot ? 'is-bot' : ''}`}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<path
|
||||
d={createArc(-135, 135)}
|
||||
fill="none"
|
||||
stroke="#1e1e2e"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{value > 0 && (
|
||||
<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
|
||||
</a>
|
||||
{Math.round(value)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue