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:
Jeff Emmett 2025-12-08 06:17:30 +01:00
parent eec7f473a8
commit 2b0c831399
14 changed files with 2618 additions and 913 deletions

View File

@ -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

View File

@ -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

1452
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

3
public/favicon.svg Normal file
View File

@ -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

View File

@ -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>

12
server/Dockerfile Normal file
View File

@ -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"]

299
server/index.js Normal file
View File

@ -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));
});

12
server/package.json Normal file
View File

@ -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"
}
}

View File

@ -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-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;
}
.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;
}
}
.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;
}
.App-logo {
height: 40vmin;
pointer-events: none;
/* Connector between dials */
.dial-connector {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
@media (max-width: 699px) {
.dial-connector {
flex-direction: column;
padding: 0.5rem 0;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
.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);
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
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;
}
}

View File

@ -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;

331
src/components/Dial.js Normal file
View File

@ -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;

View File

@ -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;
}