Menti-like with database after medlemsmöte
This commit is contained in:
		
							parent
							
								
									3f40b4b001
								
							
						
					
					
						commit
						6a6495a07d
					
				
					 11 changed files with 1514 additions and 0 deletions
				
			
		
							
								
								
									
										28
									
								
								arbetsgrupp-live/Containerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								arbetsgrupp-live/Containerfile
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					FROM node:current
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Copy package files
 | 
				
			||||||
 | 
					COPY package*.json ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Install dependencies
 | 
				
			||||||
 | 
					#RUN npm ci --omit=dev
 | 
				
			||||||
 | 
					RUN npm install -g pnpm
 | 
				
			||||||
 | 
					RUN pnpm pkg set pnpm.onlyBuiltDependencies[0]=better-sqlite3
 | 
				
			||||||
 | 
					RUN pnpm install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Copy application code
 | 
				
			||||||
 | 
					COPY . .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Create data directory for SQLite
 | 
				
			||||||
 | 
					RUN mkdir -p /app/data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Expose port
 | 
				
			||||||
 | 
					EXPOSE 3000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Set environment variables
 | 
				
			||||||
 | 
					ENV NODE_ENV=production
 | 
				
			||||||
 | 
					ENV PORT=3000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Start the application
 | 
				
			||||||
 | 
					CMD ["pnpm", "start"]
 | 
				
			||||||
							
								
								
									
										60
									
								
								arbetsgrupp-live/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								arbetsgrupp-live/README.md
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,60 @@
 | 
				
			||||||
 | 
					# Live Audience Response System
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ett webbbaserat system för att samla in svar från en live-publik och visa resultaten i realtid. Systemet är byggt med Node.js, Socket.IO och SQLite, och kan köras i en Podman-container.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Funktioner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Live-formulär**: Deltagare kan välja mellan förutbestämda alternativ eller lägga till egna
 | 
				
			||||||
 | 
					- **Flerval**: Användare kan välja flera alternativ
 | 
				
			||||||
 | 
					- **Dynamiska alternativ**: Nya alternativ som läggs till av användare blir automatiskt tillgängliga för andra
 | 
				
			||||||
 | 
					- **Live-resultat**: Admin-panel med realtidsuppdateringar av resultat
 | 
				
			||||||
 | 
					- **QR-kod**: Genereras automatiskt för enkel delning av formuläret
 | 
				
			||||||
 | 
					- **Responsiv design**: Fungerar på både desktop och mobil
 | 
				
			||||||
 | 
					- **Persistent data**: SQLite-databas för säker datalagring
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Teknisk stack
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Backend
 | 
				
			||||||
 | 
					- **Node.js** med Express.js
 | 
				
			||||||
 | 
					- **Socket.IO** för realtidskommunikation
 | 
				
			||||||
 | 
					- **SQLite** för datalagring
 | 
				
			||||||
 | 
					- **QR Code generator** för QR-kodgenerering
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Frontend
 | 
				
			||||||
 | 
					- **Vanilla JavaScript** med Socket.IO client
 | 
				
			||||||
 | 
					- **Chart.js** för datavisualisering
 | 
				
			||||||
 | 
					- **Responsiv CSS** med modern design
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Container
 | 
				
			||||||
 | 
					- **Podman** (eller Docker) för containerisering
 | 
				
			||||||
 | 
					- **Multi-stage build** för optimerad bildstorlek
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Kör applikationen
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					podman run -d --restart=on-failure --name aeroklubben-arbetsgrupp-live -p 7000:3000 -v ./data:/app/data:Z aeroklubben-arbetsgrupp-live
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Användning
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### För deltagare
 | 
				
			||||||
 | 
					1. Besök huvudsidan: `https://aeroklubben.hostux.fr`
 | 
				
			||||||
 | 
					2. Välj ett eller flera alternativ
 | 
				
			||||||
 | 
					3. Lägg till egna alternativ om önskat
 | 
				
			||||||
 | 
					4. Skicka svaret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### För administratör
 | 
				
			||||||
 | 
					1. Besök admin-panelen: `https://aeroklubben.hostux.fr/admin`
 | 
				
			||||||
 | 
					2. Visa QR-koden för deltagare
 | 
				
			||||||
 | 
					3. Följ live-resultat i realtid
 | 
				
			||||||
 | 
					4. Se statistik och diagram
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## API-endpoints
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `GET /` - Huvudformulär
 | 
				
			||||||
 | 
					- `GET /admin` - Admin-panel
 | 
				
			||||||
 | 
					- `GET /api/options` - Hämta tillgängliga alternativ
 | 
				
			||||||
 | 
					- `GET /api/results` - Hämta resultat
 | 
				
			||||||
 | 
					- `POST /api/submit` - Skicka svar
 | 
				
			||||||
 | 
					- `GET /api/qr` - Hämta QR-kod
 | 
				
			||||||
							
								
								
									
										34
									
								
								arbetsgrupp-live/build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										34
									
								
								arbetsgrupp-live/build.sh
									
									
									
									
									
										Executable file
									
								
							| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# On error, exit the script
 | 
				
			||||||
 | 
					set -e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Build script for the Live Audience Response System
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "🚀 Building Live Audience Response System..."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Build the container image
 | 
				
			||||||
 | 
					echo "📦 Building Podman container..."
 | 
				
			||||||
 | 
					podman build -t aeroklubben-arbetsgrupp-live .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Create data directory on host (for persistence)
 | 
				
			||||||
 | 
					echo "📁 Creating data directory..."
 | 
				
			||||||
 | 
					mkdir -p ./data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "✅ Build complete!"
 | 
				
			||||||
 | 
					echo ""
 | 
				
			||||||
 | 
					echo "To run the application:"
 | 
				
			||||||
 | 
					echo "  podman run -d --restart=on-failure --name aeroklubben-arbetsgrupp-live -p 7000:3000 -v ./data:/app/data:Z aeroklubben-arbetsgrupp-live"
 | 
				
			||||||
 | 
					echo ""
 | 
				
			||||||
 | 
					echo "To view logs:"
 | 
				
			||||||
 | 
					echo "  podman logs -f aeroklubben-arbetsgrupp-live"
 | 
				
			||||||
 | 
					echo ""
 | 
				
			||||||
 | 
					echo "To stop:"
 | 
				
			||||||
 | 
					echo "  podman stop aeroklubben-arbetsgrupp-live"
 | 
				
			||||||
 | 
					echo ""
 | 
				
			||||||
 | 
					echo "To remove:"
 | 
				
			||||||
 | 
					echo "  podman rm aeroklubben-arbetsgrupp-live"
 | 
				
			||||||
 | 
					echo ""
 | 
				
			||||||
 | 
					echo "Access URLs:"
 | 
				
			||||||
 | 
					echo "  Form: https://aeroklubben.hostux.fr"
 | 
				
			||||||
 | 
					echo "  Admin: https://aeroklubben.hostux.fr/admin"
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db-shm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db-shm
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db-wal
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								arbetsgrupp-live/data/responses.db-wal
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										33
									
								
								arbetsgrupp-live/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								arbetsgrupp-live/package.json
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "aeroklubben-arbetsgrupp-live",
 | 
				
			||||||
 | 
					  "version": "1.0.0",
 | 
				
			||||||
 | 
					  "description": "Live audience response collection system",
 | 
				
			||||||
 | 
					  "main": "server.js",
 | 
				
			||||||
 | 
					  "scripts": {
 | 
				
			||||||
 | 
					    "start": "node server.js",
 | 
				
			||||||
 | 
					    "dev": "nodemon server.js"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "better-sqlite3": "^12.2.0",
 | 
				
			||||||
 | 
					    "cors": "^2.8.5",
 | 
				
			||||||
 | 
					    "express": "^5.1.0",
 | 
				
			||||||
 | 
					    "qrcode": "^1.5.3",
 | 
				
			||||||
 | 
					    "socket.io": "^4.7.2"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "nodemon": "^3.0.1"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "keywords": [
 | 
				
			||||||
 | 
					    "audience",
 | 
				
			||||||
 | 
					    "response",
 | 
				
			||||||
 | 
					    "live",
 | 
				
			||||||
 | 
					    "polling"
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "author": "",
 | 
				
			||||||
 | 
					  "license": "MIT",
 | 
				
			||||||
 | 
					  "pnpm": {
 | 
				
			||||||
 | 
					    "onlyBuiltDependencies": [
 | 
				
			||||||
 | 
					      "better-sqlite3"
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										704
									
								
								arbetsgrupp-live/public/admin.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										704
									
								
								arbetsgrupp-live/public/admin.html
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,704 @@
 | 
				
			||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="sv">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					    <title>Live Resultat - Arbetsgrupper</title>
 | 
				
			||||||
 | 
					    <script src="/socket.io/socket.io.js"></script>
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					        * {
 | 
				
			||||||
 | 
					            box-sizing: border-box;
 | 
				
			||||||
 | 
					            margin: 0;
 | 
				
			||||||
 | 
					            padding: 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        body {
 | 
				
			||||||
 | 
					            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 | 
				
			||||||
 | 
					            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            min-height: 100vh;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .header {
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            margin-bottom: 40px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .header h1 {
 | 
				
			||||||
 | 
					            font-size: 2.5rem;
 | 
				
			||||||
 | 
					            margin-bottom: 10px;
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #3498db, #2ecc71);
 | 
				
			||||||
 | 
					            -webkit-background-clip: text;
 | 
				
			||||||
 | 
					            -webkit-text-fill-color: transparent;
 | 
				
			||||||
 | 
					            background-clip: text;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .header p {
 | 
				
			||||||
 | 
					            font-size: 1.2rem;
 | 
				
			||||||
 | 
					            opacity: 0.8;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .dashboard {
 | 
				
			||||||
 | 
					            display: grid;
 | 
				
			||||||
 | 
					            grid-template-columns: auto 1fr;
 | 
				
			||||||
 | 
					            gap: 30px;
 | 
				
			||||||
 | 
					            max-width: 1400px;
 | 
				
			||||||
 | 
					            margin: 0 auto;
 | 
				
			||||||
 | 
					            align-items: start;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .card {
 | 
				
			||||||
 | 
					            background: rgba(255, 255, 255, 0.1);
 | 
				
			||||||
 | 
					            border-radius: 20px;
 | 
				
			||||||
 | 
					            padding: 30px;
 | 
				
			||||||
 | 
					            backdrop-filter: blur(10px);
 | 
				
			||||||
 | 
					            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
 | 
				
			||||||
 | 
					            border: 1px solid rgba(255, 255, 255, 0.1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .qr-section {
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            min-width: 300px;
 | 
				
			||||||
 | 
					            max-width: 350px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .qr-section h2 {
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					            color: #3498db;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .qr-code {
 | 
				
			||||||
 | 
					            background: white;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					            border-radius: 15px;
 | 
				
			||||||
 | 
					            display: inline-block;
 | 
				
			||||||
 | 
					            margin-bottom: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .qr-code img {
 | 
				
			||||||
 | 
					            max-width: 200px;
 | 
				
			||||||
 | 
					            height: auto;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .url-display {
 | 
				
			||||||
 | 
					            background: rgba(0, 0, 0, 0.3);
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            font-family: monospace;
 | 
				
			||||||
 | 
					            word-break: break-all;
 | 
				
			||||||
 | 
					            margin-top: 10px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .results-section {
 | 
				
			||||||
 | 
					            flex: 1;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .results-section h2 {
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					            color: #2ecc71;
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            gap: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .connection-status {
 | 
				
			||||||
 | 
					            display: inline-flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            padding: 6px 12px;
 | 
				
			||||||
 | 
					            border-radius: 15px;
 | 
				
			||||||
 | 
					            font-size: 0.8rem;
 | 
				
			||||||
 | 
					            font-weight: bold;
 | 
				
			||||||
 | 
					            transition: all 0.3s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .connection-status.connected {
 | 
				
			||||||
 | 
					            background: #27ae60;
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .connection-status.disconnected {
 | 
				
			||||||
 | 
					            background: #e74c3c;
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .status-dot {
 | 
				
			||||||
 | 
					            width: 6px;
 | 
				
			||||||
 | 
					            height: 6px;
 | 
				
			||||||
 | 
					            border-radius: 50%;
 | 
				
			||||||
 | 
					            margin-right: 6px;
 | 
				
			||||||
 | 
					            transition: all 0.3s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .connected .status-dot {
 | 
				
			||||||
 | 
					            background: white;
 | 
				
			||||||
 | 
					            animation: pulse 2s infinite;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .disconnected .status-dot {
 | 
				
			||||||
 | 
					            background: white;
 | 
				
			||||||
 | 
					            animation: none;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .floating-results {
 | 
				
			||||||
 | 
					            position: relative;
 | 
				
			||||||
 | 
					            min-height: 500px;
 | 
				
			||||||
 | 
					            background: rgba(0, 0, 0, 0.2);
 | 
				
			||||||
 | 
					            border-radius: 15px;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					            overflow: hidden;
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            gap: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option-container {
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            background: rgba(255, 255, 255, 0.05);
 | 
				
			||||||
 | 
					            border-radius: 12px;
 | 
				
			||||||
 | 
					            border-left: 4px solid #3498db;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option-text {
 | 
				
			||||||
 | 
					            font-size: 1.3rem;
 | 
				
			||||||
 | 
					            font-weight: 600;
 | 
				
			||||||
 | 
					            color: #3498db;
 | 
				
			||||||
 | 
					            margin-bottom: 15px;
 | 
				
			||||||
 | 
					            text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participants-cloud {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-direction: column;
 | 
				
			||||||
 | 
					            gap: 8px;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #3498db, #2ecc71);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            padding: 6px 12px;
 | 
				
			||||||
 | 
					            border-radius: 20px;
 | 
				
			||||||
 | 
					            font-size: 0.85rem;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					            box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
 | 
				
			||||||
 | 
					            animation: cloudFloat 3s ease-in-out infinite;
 | 
				
			||||||
 | 
					            position: relative;
 | 
				
			||||||
 | 
					            overflow: hidden;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud:nth-child(2n) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #e74c3c, #f39c12);
 | 
				
			||||||
 | 
					            box-shadow: 0 2px 8px rgba(231, 76, 60, 0.3);
 | 
				
			||||||
 | 
					            animation-delay: -1s;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud:nth-child(3n) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #9b59b6, #e67e22);
 | 
				
			||||||
 | 
					            box-shadow: 0 2px 8px rgba(155, 89, 182, 0.3);
 | 
				
			||||||
 | 
					            animation-delay: -2s;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud:nth-child(4n) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #1abc9c, #27ae60);
 | 
				
			||||||
 | 
					            box-shadow: 0 2px 8px rgba(26, 188, 156, 0.3);
 | 
				
			||||||
 | 
					            animation-delay: -0.5s;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud:nth-child(5n) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #f39c12, #e67e22);
 | 
				
			||||||
 | 
					            box-shadow: 0 2px 8px rgba(243, 156, 18, 0.3);
 | 
				
			||||||
 | 
					            animation-delay: -1.5s;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @keyframes cloudFloat {
 | 
				
			||||||
 | 
					            0%, 100% {
 | 
				
			||||||
 | 
					                transform: translateY(0px) scale(1);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            33% {
 | 
				
			||||||
 | 
					                transform: translateY(-3px) scale(1.02);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            66% {
 | 
				
			||||||
 | 
					                transform: translateY(1px) scale(0.98);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-cloud::before {
 | 
				
			||||||
 | 
					            content: '';
 | 
				
			||||||
 | 
					            position: absolute;
 | 
				
			||||||
 | 
					            top: 0;
 | 
				
			||||||
 | 
					            left: -100%;
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            height: 100%;
 | 
				
			||||||
 | 
					            background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
 | 
				
			||||||
 | 
					            animation: shimmer 4s infinite;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @keyframes shimmer {
 | 
				
			||||||
 | 
					            0% { left: -100%; }
 | 
				
			||||||
 | 
					            100% { left: 100%; }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option-count {
 | 
				
			||||||
 | 
					            background: rgba(52, 152, 219, 0.8);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            padding: 4px 10px;
 | 
				
			||||||
 | 
					            border-radius: 12px;
 | 
				
			||||||
 | 
					            font-size: 0.8rem;
 | 
				
			||||||
 | 
					            font-weight: bold;
 | 
				
			||||||
 | 
					            margin-left: 10px;
 | 
				
			||||||
 | 
					            display: inline-block;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .chart-container {
 | 
				
			||||||
 | 
					            position: relative;
 | 
				
			||||||
 | 
					            height: 400px;
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .stats-grid {
 | 
				
			||||||
 | 
					            display: grid;
 | 
				
			||||||
 | 
					            grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
 | 
				
			||||||
 | 
					            gap: 15px;
 | 
				
			||||||
 | 
					            margin-top: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .stat-card {
 | 
				
			||||||
 | 
					            background: rgba(255, 255, 255, 0.1);
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .stat-value {
 | 
				
			||||||
 | 
					            font-size: 2rem;
 | 
				
			||||||
 | 
					            font-weight: bold;
 | 
				
			||||||
 | 
					            color: #3498db;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .stat-label {
 | 
				
			||||||
 | 
					            font-size: 0.9rem;
 | 
				
			||||||
 | 
					            opacity: 0.8;
 | 
				
			||||||
 | 
					            margin-top: 5px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .live-indicator {
 | 
				
			||||||
 | 
					            display: inline-flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            background: #e74c3c;
 | 
				
			||||||
 | 
					            padding: 8px 15px;
 | 
				
			||||||
 | 
					            border-radius: 20px;
 | 
				
			||||||
 | 
					            font-size: 0.9rem;
 | 
				
			||||||
 | 
					            font-weight: bold;
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .live-dot {
 | 
				
			||||||
 | 
					            width: 8px;
 | 
				
			||||||
 | 
					            height: 8px;
 | 
				
			||||||
 | 
					            background: white;
 | 
				
			||||||
 | 
					            border-radius: 50%;
 | 
				
			||||||
 | 
					            margin-right: 8px;
 | 
				
			||||||
 | 
					            animation: pulse 2s infinite;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @keyframes pulse {
 | 
				
			||||||
 | 
					            0% { opacity: 1; }
 | 
				
			||||||
 | 
					            50% { opacity: 0.5; }
 | 
				
			||||||
 | 
					            100% { opacity: 1; }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .response-list {
 | 
				
			||||||
 | 
					            max-height: 300px;
 | 
				
			||||||
 | 
					            overflow-y: auto;
 | 
				
			||||||
 | 
					            background: rgba(0, 0, 0, 0.2);
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            margin-top: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .response-item {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            justify-content: space-between;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            padding: 10px;
 | 
				
			||||||
 | 
					            margin-bottom: 8px;
 | 
				
			||||||
 | 
					            background: rgba(255, 255, 255, 0.1);
 | 
				
			||||||
 | 
					            border-radius: 8px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .response-text {
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .response-count {
 | 
				
			||||||
 | 
					            background: #3498db;
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            padding: 4px 12px;
 | 
				
			||||||
 | 
					            border-radius: 15px;
 | 
				
			||||||
 | 
					            font-size: 0.9rem;
 | 
				
			||||||
 | 
					            font-weight: bold;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participants-grid {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-wrap: wrap;
 | 
				
			||||||
 | 
					            gap: 15px;
 | 
				
			||||||
 | 
					            margin-top: 20px;
 | 
				
			||||||
 | 
					            max-height: 400px;
 | 
				
			||||||
 | 
					            overflow-y: auto;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					            background: rgba(0, 0, 0, 0.2);
 | 
				
			||||||
 | 
					            border-radius: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #3498db, #2ecc71);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            padding: 12px 18px;
 | 
				
			||||||
 | 
					            border-radius: 25px;
 | 
				
			||||||
 | 
					            font-size: 0.9rem;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            min-width: 120px;
 | 
				
			||||||
 | 
					            box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3);
 | 
				
			||||||
 | 
					            animation: bubbleIn 0.5s ease-out;
 | 
				
			||||||
 | 
					            transition: transform 0.3s ease;
 | 
				
			||||||
 | 
					            position: relative;
 | 
				
			||||||
 | 
					            overflow: hidden;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble:hover {
 | 
				
			||||||
 | 
					            transform: translateY(-3px);
 | 
				
			||||||
 | 
					            box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble::before {
 | 
				
			||||||
 | 
					            content: '';
 | 
				
			||||||
 | 
					            position: absolute;
 | 
				
			||||||
 | 
					            top: 0;
 | 
				
			||||||
 | 
					            left: -100%;
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            height: 100%;
 | 
				
			||||||
 | 
					            background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
 | 
				
			||||||
 | 
					            animation: shimmer 2s infinite;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-name {
 | 
				
			||||||
 | 
					            font-weight: 600;
 | 
				
			||||||
 | 
					            margin-bottom: 4px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-choice {
 | 
				
			||||||
 | 
					            font-size: 0.8rem;
 | 
				
			||||||
 | 
					            opacity: 0.9;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @keyframes bubbleIn {
 | 
				
			||||||
 | 
					            0% {
 | 
				
			||||||
 | 
					                opacity: 0;
 | 
				
			||||||
 | 
					                transform: scale(0.5) translateY(20px);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            100% {
 | 
				
			||||||
 | 
					                opacity: 1;
 | 
				
			||||||
 | 
					                transform: scale(1) translateY(0);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @keyframes shimmer {
 | 
				
			||||||
 | 
					            0% { left: -100%; }
 | 
				
			||||||
 | 
					            100% { left: 100%; }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .bubble-colors {
 | 
				
			||||||
 | 
					            --color1: #3498db;
 | 
				
			||||||
 | 
					            --color2: #2ecc71;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble:nth-child(3n+1) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #e74c3c, #f39c12);
 | 
				
			||||||
 | 
					            box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble:nth-child(3n+2) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #9b59b6, #e67e22);
 | 
				
			||||||
 | 
					            box-shadow: 0 4px 15px rgba(155, 89, 182, 0.3);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .participant-bubble:nth-child(3n+3) {
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #1abc9c, #27ae60);
 | 
				
			||||||
 | 
					            box-shadow: 0 4px 15px rgba(26, 188, 156, 0.3);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @media (max-width: 1024px) {
 | 
				
			||||||
 | 
					            .dashboard {
 | 
				
			||||||
 | 
					                grid-template-columns: 1fr;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            .qr-section {
 | 
				
			||||||
 | 
					                max-width: 100%;
 | 
				
			||||||
 | 
					                order: -1;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @media (max-width: 768px) {
 | 
				
			||||||
 | 
					            .header h1 {
 | 
				
			||||||
 | 
					                font-size: 2rem;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            .card {
 | 
				
			||||||
 | 
					                padding: 20px;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            .stats-grid {
 | 
				
			||||||
 | 
					                grid-template-columns: repeat(2, 1fr);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					    <div class="header">
 | 
				
			||||||
 | 
					        <h1>
 | 
				
			||||||
 | 
					            Vi behöver DIG! Vilken arbetsgrupp skulle du kunna tänka dig delta i?
 | 
				
			||||||
 | 
					        </h1>
 | 
				
			||||||
 | 
					        <p>
 | 
				
			||||||
 | 
					            <span class="connection-status disconnected" id="connectionStatus">
 | 
				
			||||||
 | 
					                <span class="status-dot"></span>
 | 
				
			||||||
 | 
					                <span class="status-text">DISCONNECTED</span>
 | 
				
			||||||
 | 
					            </span>
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="dashboard">
 | 
				
			||||||
 | 
					        <div class="card qr-section">
 | 
				
			||||||
 | 
					            <h2>Scanna för att svara</h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="qr-code" id="qrContainer">
 | 
				
			||||||
 | 
					                <p>Laddar QR-kod...</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="url-display" id="urlDisplay">
 | 
				
			||||||
 | 
					                Laddar URL...
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <div class="stats-grid">
 | 
				
			||||||
 | 
					                <div class="stat-card">
 | 
				
			||||||
 | 
					                    <div class="stat-value" id="uniqueParticipants">0</div>
 | 
				
			||||||
 | 
					                    <div class="stat-label">Deltagare</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="card results-section">
 | 
				
			||||||
 | 
					            <h2>
 | 
				
			||||||
 | 
					                Live Resultat
 | 
				
			||||||
 | 
					            </h2>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <div class="floating-results" id="results">
 | 
				
			||||||
 | 
					                <p style="text-align: center; opacity: 0.7;">Väntar på svar...</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <script>
 | 
				
			||||||
 | 
					        const socket = io();
 | 
				
			||||||
 | 
					        let results = [];
 | 
				
			||||||
 | 
					        let allResponses = [];
 | 
				
			||||||
 | 
					        let availableOptions = [];
 | 
				
			||||||
 | 
					        let participantColour = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Chart colors
 | 
				
			||||||
 | 
					        const colors = [
 | 
				
			||||||
 | 
					            '#3498db', '#2ecc71', '#e74c3c', '#f39c12', 
 | 
				
			||||||
 | 
					            '#9b59b6', '#1abc9c', '#34495e', '#e67e22',
 | 
				
			||||||
 | 
					            '#95a5a6', '#c0392b', '#8e44ad', '#27ae60'
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function updateResults() {
 | 
				
			||||||
 | 
					            // Show a container box for each option, with the option text and a count of responses
 | 
				
			||||||
 | 
					            // In each box, show the list of participants who chose that option
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const resultsContainer = document.getElementById('results');
 | 
				
			||||||
 | 
					            resultsContainer.innerHTML = ''; // Clear previous results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            results.forEach(result => {
 | 
				
			||||||
 | 
					                const optionContainer = document.createElement('div');
 | 
				
			||||||
 | 
					                optionContainer.classList.add('option-container');
 | 
				
			||||||
 | 
					                optionContainer.setAttribute('data-option', result.response);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                const optionText = document.createElement('h3');
 | 
				
			||||||
 | 
					                optionText.classList.add('option-text');
 | 
				
			||||||
 | 
					                optionText.textContent = result.response;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                const optionCount = document.createElement('span');
 | 
				
			||||||
 | 
					                optionCount.classList.add('option-count');
 | 
				
			||||||
 | 
					                optionCount.textContent = result.count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                optionText.appendChild(optionCount);
 | 
				
			||||||
 | 
					                optionContainer.appendChild(optionText);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                const participantsCloud = document.createElement('div');
 | 
				
			||||||
 | 
					                participantsCloud.classList.add('participants-cloud');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                optionContainer.appendChild(participantsCloud);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                resultsContainer.appendChild(optionContainer);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            allResponses.forEach(response => {
 | 
				
			||||||
 | 
					                const participantsCloud = document.querySelector(`.option-container[data-option="${response.response}"] .participants-cloud`);
 | 
				
			||||||
 | 
					                if (participantsCloud) {
 | 
				
			||||||
 | 
					                    const participantBubble = document.createElement('div');
 | 
				
			||||||
 | 
					                    participantBubble.classList.add('participant-cloud');
 | 
				
			||||||
 | 
					                    participantBubble.innerText = response.participant_name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    participantsCloud.appendChild(participantBubble);           
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            console.log('Available options updated:', availableOptions);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            availableOptions.forEach(option => {
 | 
				
			||||||
 | 
					                if (!results.some(r => r.response === option)) {
 | 
				
			||||||
 | 
					                    const optionContainer = document.createElement('div');
 | 
				
			||||||
 | 
					                    optionContainer.classList.add('option-container');
 | 
				
			||||||
 | 
					                    optionContainer.setAttribute('data-option', option);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    const optionText = document.createElement('h3');
 | 
				
			||||||
 | 
					                    optionText.classList.add('option-text');
 | 
				
			||||||
 | 
					                    optionText.textContent = option;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    const optionCount = document.createElement('span');
 | 
				
			||||||
 | 
					                    optionCount.classList.add('option-count');
 | 
				
			||||||
 | 
					                    optionCount.textContent = '0';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    optionText.appendChild(optionCount);
 | 
				
			||||||
 | 
					                    optionContainer.appendChild(optionText);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    resultsContainer.appendChild(optionContainer);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function updateStats() {
 | 
				
			||||||
 | 
					            const uniqueParticipants = new Set(allResponses.map(r => r.participant_name)).size;
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            // Update participants count
 | 
				
			||||||
 | 
					            const participantCountElement = document.querySelector('.stat-card:last-child .stat-value');
 | 
				
			||||||
 | 
					            participantCountElement.textContent = uniqueParticipants;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function loadQRCode() {
 | 
				
			||||||
 | 
					            fetch('/api/qr')
 | 
				
			||||||
 | 
					                .then(response => {
 | 
				
			||||||
 | 
					                    if (!response.ok) {
 | 
				
			||||||
 | 
					                        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    return response.json();
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .then(data => {
 | 
				
			||||||
 | 
					                    if (data.qrCode) {
 | 
				
			||||||
 | 
					                        const qrContainer = document.getElementById('qrContainer');
 | 
				
			||||||
 | 
					                        qrContainer.innerHTML = `<img src="${data.qrCode}" alt="QR Code" style="max-width: 100%; height: auto;">`;
 | 
				
			||||||
 | 
					                        
 | 
				
			||||||
 | 
					                        const urlDisplay = document.getElementById('urlDisplay');
 | 
				
			||||||
 | 
					                        urlDisplay.textContent = window.location.origin;
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        throw new Error('No QR code data received');
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .catch(error => {
 | 
				
			||||||
 | 
					                    console.error('Error loading QR code:', error);
 | 
				
			||||||
 | 
					                    document.getElementById('qrContainer').innerHTML = `<p style="color: #e74c3c;">Fel vid laddning av QR-kod: ${error.message}</p>`;
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Socket.IO event listeners
 | 
				
			||||||
 | 
					        socket.on('resultsUpdated', (newResults) => {
 | 
				
			||||||
 | 
					            results = newResults;
 | 
				
			||||||
 | 
					            updateResults();
 | 
				
			||||||
 | 
					            updateStats();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        socket.on('allResponsesUpdated', (newAllResponses) => {
 | 
				
			||||||
 | 
					            allResponses = newAllResponses;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            allResponses.forEach(response => {
 | 
				
			||||||
 | 
					                if (!participantColour[response.participant_name]) {
 | 
				
			||||||
 | 
					                    const colourIndex = Object.keys(participantColour).length % colors.length;
 | 
				
			||||||
 | 
					                    participantColour[response.participant_name] = colors[colourIndex];
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            updateResults();
 | 
				
			||||||
 | 
					            updateStats();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Socket.IO event listeners
 | 
				
			||||||
 | 
					        socket.on('optionsUpdated', (options) => {
 | 
				
			||||||
 | 
					            availableOptions = options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            updateResults();
 | 
				
			||||||
 | 
					            updateStats();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        socket.on('disconnect', () => {
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.remove('connected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.add('disconnected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').querySelector('.status-text').innerText = 'DISCONNECTED';
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        socket.io.on('error', () => {
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.remove('connected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.add('disconnected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').querySelector('.status-text').innerText = 'DISCONNECTED';
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        socket.on('connect', () => {
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.remove('disconnected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').classList.add('connected');
 | 
				
			||||||
 | 
					            document.getElementById('connectionStatus').querySelector('.status-text').innerText = 'LIVE';
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initialize
 | 
				
			||||||
 | 
					        document.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					            loadQRCode();
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            // Load initial data
 | 
				
			||||||
 | 
					            fetch('/api/results')
 | 
				
			||||||
 | 
					                .then(response => response.json())
 | 
				
			||||||
 | 
					                .then(data => {
 | 
				
			||||||
 | 
					                    results = data;
 | 
				
			||||||
 | 
					                    updateResults();
 | 
				
			||||||
 | 
					                    updateStats();
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .catch(error => {
 | 
				
			||||||
 | 
					                    console.error('Error loading initial results:', error);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            fetch('/api/all-responses')
 | 
				
			||||||
 | 
					                .then(response => response.json())
 | 
				
			||||||
 | 
					                .then(data => {
 | 
				
			||||||
 | 
					                    allResponses = data;
 | 
				
			||||||
 | 
					                    updateResults();
 | 
				
			||||||
 | 
					                    updateStats();
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .catch(error => {
 | 
				
			||||||
 | 
					                    console.error('Error loading initial responses:', error);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            fetch('/api/options')
 | 
				
			||||||
 | 
					                .then(response => response.json())
 | 
				
			||||||
 | 
					                .then(data => {
 | 
				
			||||||
 | 
					                    availableOptions = options;
 | 
				
			||||||
 | 
					                    updateResults();
 | 
				
			||||||
 | 
					                    updateStats();
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .catch(error => {
 | 
				
			||||||
 | 
					                    console.error('Error loading options:', error);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    </script>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								arbetsgrupp-live/public/aeroklubben.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								arbetsgrupp-live/public/aeroklubben.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 120 KiB  | 
							
								
								
									
										397
									
								
								arbetsgrupp-live/public/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										397
									
								
								arbetsgrupp-live/public/index.html
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,397 @@
 | 
				
			||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="sv">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					    <title>Arbetsgrupp - Deltagarformulär</title>
 | 
				
			||||||
 | 
					    <script src="/socket.io/socket.io.js"></script>
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					        * {
 | 
				
			||||||
 | 
					            box-sizing: border-box;
 | 
				
			||||||
 | 
					            margin: 0;
 | 
				
			||||||
 | 
					            padding: 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        body {
 | 
				
			||||||
 | 
					            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 | 
				
			||||||
 | 
					            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
				
			||||||
 | 
					            min-height: 100vh;
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            justify-content: center;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .container {
 | 
				
			||||||
 | 
					            background: rgba(255, 255, 255, 0.95);
 | 
				
			||||||
 | 
					            border-radius: 20px;
 | 
				
			||||||
 | 
					            padding: 40px;
 | 
				
			||||||
 | 
					            max-width: 600px;
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
 | 
				
			||||||
 | 
					            backdrop-filter: blur(10px);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .logo {
 | 
				
			||||||
 | 
					            display: block;
 | 
				
			||||||
 | 
					            margin: 0 auto 20px;
 | 
				
			||||||
 | 
					            height: auto;
 | 
				
			||||||
 | 
					            max-width: 100%;
 | 
				
			||||||
 | 
					            width: 7em;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        h1 {
 | 
				
			||||||
 | 
					            color: #2c3e50;
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            margin-bottom: 30px;
 | 
				
			||||||
 | 
					            font-size: 2rem;
 | 
				
			||||||
 | 
					            font-weight: 600;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .question {
 | 
				
			||||||
 | 
					            background: #f8f9fa;
 | 
				
			||||||
 | 
					            padding: 20px;
 | 
				
			||||||
 | 
					            border-radius: 12px;
 | 
				
			||||||
 | 
					            margin-bottom: 30px;
 | 
				
			||||||
 | 
					            border-left: 4px solid #667eea;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .question h2 {
 | 
				
			||||||
 | 
					            color: #2c3e50;
 | 
				
			||||||
 | 
					            font-size: 1.3rem;
 | 
				
			||||||
 | 
					            margin-bottom: 10px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .options {
 | 
				
			||||||
 | 
					            display: grid;
 | 
				
			||||||
 | 
					            gap: 15px;
 | 
				
			||||||
 | 
					            margin-bottom: 25px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            align-items: center;
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            background: white;
 | 
				
			||||||
 | 
					            border: 2px solid #e9ecef;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            transition: all 0.3s ease;
 | 
				
			||||||
 | 
					            position: relative;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option:hover {
 | 
				
			||||||
 | 
					            border-color: #667eea;
 | 
				
			||||||
 | 
					            transform: translateY(-2px);
 | 
				
			||||||
 | 
					            box-shadow: 0 4px 15px rgba(102, 126, 234, 0.2);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option.selected {
 | 
				
			||||||
 | 
					            border-color: #667eea;
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #667eea, #764ba2);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option input[type="checkbox"] {
 | 
				
			||||||
 | 
					            margin-right: 12px;
 | 
				
			||||||
 | 
					            transform: scale(1.2);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .option label {
 | 
				
			||||||
 | 
					            flex: 1;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .custom-input-container {
 | 
				
			||||||
 | 
					            margin-top: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .custom-input {
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            border: 2px solid #e9ecef;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            font-size: 16px;
 | 
				
			||||||
 | 
					            transition: border-color 0.3s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .custom-input:focus {
 | 
				
			||||||
 | 
					            outline: none;
 | 
				
			||||||
 | 
					            border-color: #667eea;
 | 
				
			||||||
 | 
					            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .add-custom {
 | 
				
			||||||
 | 
					            margin-top: 10px;
 | 
				
			||||||
 | 
					            background: #28a745;
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            border: none;
 | 
				
			||||||
 | 
					            padding: 10px 20px;
 | 
				
			||||||
 | 
					            border-radius: 8px;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            font-size: 14px;
 | 
				
			||||||
 | 
					            transition: background-color 0.3s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .add-custom:hover {
 | 
				
			||||||
 | 
					            background: #218838;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .submit-btn {
 | 
				
			||||||
 | 
					            width: 100%;
 | 
				
			||||||
 | 
					            background: linear-gradient(45deg, #667eea, #764ba2);
 | 
				
			||||||
 | 
					            color: white;
 | 
				
			||||||
 | 
					            border: none;
 | 
				
			||||||
 | 
					            padding: 18px;
 | 
				
			||||||
 | 
					            border-radius: 12px;
 | 
				
			||||||
 | 
					            font-size: 18px;
 | 
				
			||||||
 | 
					            font-weight: 600;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            transition: all 0.3s ease;
 | 
				
			||||||
 | 
					            margin-top: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .submit-btn:hover {
 | 
				
			||||||
 | 
					            transform: translateY(-2px);
 | 
				
			||||||
 | 
					            box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .submit-btn:disabled {
 | 
				
			||||||
 | 
					            background: #6c757d;
 | 
				
			||||||
 | 
					            cursor: not-allowed;
 | 
				
			||||||
 | 
					            transform: none;
 | 
				
			||||||
 | 
					            box-shadow: none;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .success-message {
 | 
				
			||||||
 | 
					            background: #d4edda;
 | 
				
			||||||
 | 
					            color: #155724;
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .error-message {
 | 
				
			||||||
 | 
					            background: #f8d7da;
 | 
				
			||||||
 | 
					            color: #721c24;
 | 
				
			||||||
 | 
					            padding: 15px;
 | 
				
			||||||
 | 
					            border-radius: 10px;
 | 
				
			||||||
 | 
					            margin-bottom: 20px;
 | 
				
			||||||
 | 
					            text-align: center;
 | 
				
			||||||
 | 
					            font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @media (max-width: 768px) {
 | 
				
			||||||
 | 
					            .container {
 | 
				
			||||||
 | 
					                padding: 25px;
 | 
				
			||||||
 | 
					                margin: 10px;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            h1 {
 | 
				
			||||||
 | 
					                font-size: 1.5rem;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					    <div class="container">
 | 
				
			||||||
 | 
					        <img src="/aeroklubben.png" alt="Aeroklubben i Göteborg" class="logo" />
 | 
				
			||||||
 | 
					        <h1>Vi behöver DIG!</h1>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div id="message"></div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <form id="responseForm">
 | 
				
			||||||
 | 
					            <div class="question">
 | 
				
			||||||
 | 
					                <h2>Vilken arbetsgrupp skulle du kunna tänka dig delta i?</h2>
 | 
				
			||||||
 | 
					                <p style="color: #6c757d; margin-top: 5px;">Du kan välja flera alternativ</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div style="margin-bottom: 25px;">
 | 
				
			||||||
 | 
					                <input 
 | 
				
			||||||
 | 
					                    type="text" 
 | 
				
			||||||
 | 
					                    id="participantName" 
 | 
				
			||||||
 | 
					                    class="custom-input" 
 | 
				
			||||||
 | 
					                    placeholder="Ditt namn *" 
 | 
				
			||||||
 | 
					                    required
 | 
				
			||||||
 | 
					                    style="margin-bottom: 20px;"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <div class="options" id="options">
 | 
				
			||||||
 | 
					                <!-- Options will be populated dynamically -->
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <div class="custom-input-container">
 | 
				
			||||||
 | 
					                <input 
 | 
				
			||||||
 | 
					                    type="text" 
 | 
				
			||||||
 | 
					                    id="customInput" 
 | 
				
			||||||
 | 
					                    class="custom-input" 
 | 
				
			||||||
 | 
					                    placeholder="Eller skriv ditt eget alternativ..."
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                <button type="button" id="addCustom" class="add-custom">Lägg till</button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <button type="submit" id="submitBtn" class="submit-btn">Skicka svar</button>
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <script>
 | 
				
			||||||
 | 
					        const socket = io();
 | 
				
			||||||
 | 
					        const form = document.getElementById('responseForm');
 | 
				
			||||||
 | 
					        const optionsContainer = document.getElementById('options');
 | 
				
			||||||
 | 
					        const customInput = document.getElementById('customInput');
 | 
				
			||||||
 | 
					        const addCustomBtn = document.getElementById('addCustom');
 | 
				
			||||||
 | 
					        const submitBtn = document.getElementById('submitBtn');
 | 
				
			||||||
 | 
					        const messageDiv = document.getElementById('message');
 | 
				
			||||||
 | 
					        const participantNameInput = document.getElementById('participantName');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let availableOptions = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function showMessage(text, type = 'success') {
 | 
				
			||||||
 | 
					            messageDiv.innerHTML = `<div class="${type}-message">${text}</div>`;
 | 
				
			||||||
 | 
					            scrollTo(0, 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            setTimeout(() => {
 | 
				
			||||||
 | 
					                messageDiv.innerHTML = '';
 | 
				
			||||||
 | 
					            }, 5000);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function renderOptions() {
 | 
				
			||||||
 | 
					            // Store currently selected options before re-rendering
 | 
				
			||||||
 | 
					            const currentlySelected = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
 | 
				
			||||||
 | 
					                .map(checkbox => checkbox.value);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            optionsContainer.innerHTML = '';
 | 
				
			||||||
 | 
					            availableOptions.forEach((option, index) => {
 | 
				
			||||||
 | 
					                const optionDiv = document.createElement('div');
 | 
				
			||||||
 | 
					                optionDiv.className = 'option';
 | 
				
			||||||
 | 
					                optionDiv.innerHTML = `
 | 
				
			||||||
 | 
					                    <input type="checkbox" id="option${index}" value="${option}">
 | 
				
			||||||
 | 
					                    <label for="option${index}">${option}</label>
 | 
				
			||||||
 | 
					                `;
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                const checkbox = optionDiv.querySelector('input');
 | 
				
			||||||
 | 
					                const label = optionDiv.querySelector('label');
 | 
				
			||||||
 | 
					                label.addEventListener('click', (e) => {
 | 
				
			||||||
 | 
					                    e.stopPropagation(); // Prevent triggering the div click event
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                // Restore selection state if this option was previously selected
 | 
				
			||||||
 | 
					                if (currentlySelected.includes(option)) {
 | 
				
			||||||
 | 
					                    checkbox.checked = true;
 | 
				
			||||||
 | 
					                    optionDiv.classList.add('selected');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                checkbox.addEventListener('change', () => {
 | 
				
			||||||
 | 
					                    if (checkbox.checked) {
 | 
				
			||||||
 | 
					                        optionDiv.classList.add('selected');
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        optionDiv.classList.remove('selected');
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                optionDiv.addEventListener('click', (e) => {
 | 
				
			||||||
 | 
					                    if (e.target !== checkbox) {
 | 
				
			||||||
 | 
					                        checkbox.checked = !checkbox.checked;
 | 
				
			||||||
 | 
					                        checkbox.dispatchEvent(new Event('change'));
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                optionsContainer.appendChild(optionDiv);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function addCustomOption() {
 | 
				
			||||||
 | 
					            const customValue = customInput.value.trim();
 | 
				
			||||||
 | 
					            if (customValue && !availableOptions.includes(customValue)) {
 | 
				
			||||||
 | 
					                availableOptions.push(customValue);
 | 
				
			||||||
 | 
					                renderOptions();
 | 
				
			||||||
 | 
					                customInput.value = '';
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                // Auto-select the newly added option
 | 
				
			||||||
 | 
					                const newCheckbox = document.querySelector(`input[value="${customValue}"]`);
 | 
				
			||||||
 | 
					                if (newCheckbox) {
 | 
				
			||||||
 | 
					                    newCheckbox.checked = true;
 | 
				
			||||||
 | 
					                    newCheckbox.dispatchEvent(new Event('change'));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        addCustomBtn.addEventListener('click', addCustomOption);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        customInput.addEventListener('keypress', (e) => {
 | 
				
			||||||
 | 
					            if (e.key === 'Enter') {
 | 
				
			||||||
 | 
					                e.preventDefault();
 | 
				
			||||||
 | 
					                addCustomOption();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        form.addEventListener('submit', async (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            const participantName = participantNameInput.value.trim();
 | 
				
			||||||
 | 
					            const selectedOptions = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
 | 
				
			||||||
 | 
					                .map(checkbox => checkbox.value);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            if (!participantName) {
 | 
				
			||||||
 | 
					                showMessage('Ange ditt namn', 'error');
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            if (selectedOptions.length === 0) {
 | 
				
			||||||
 | 
					                showMessage('Välj minst ett alternativ', 'error');
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            submitBtn.disabled = true;
 | 
				
			||||||
 | 
					            submitBtn.textContent = 'Skickar...';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                const response = await fetch('/api/submit', {
 | 
				
			||||||
 | 
					                    method: 'POST',
 | 
				
			||||||
 | 
					                    headers: {
 | 
				
			||||||
 | 
					                        'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    body: JSON.stringify({ 
 | 
				
			||||||
 | 
					                        responses: selectedOptions,
 | 
				
			||||||
 | 
					                        participantName: participantName
 | 
				
			||||||
 | 
					                    }),
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (response.ok) {
 | 
				
			||||||
 | 
					                    showMessage('Tack för ditt svar!');
 | 
				
			||||||
 | 
					                    form.reset();
 | 
				
			||||||
 | 
					                    document.querySelectorAll('.option').forEach(opt => opt.classList.remove('selected'));
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    throw new Error('Server error');
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                showMessage('Ett fel uppstod. Försök igen.', 'error');
 | 
				
			||||||
 | 
					            } finally {
 | 
				
			||||||
 | 
					                submitBtn.disabled = false;
 | 
				
			||||||
 | 
					                submitBtn.textContent = 'Skicka svar';
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Socket.IO event listeners
 | 
				
			||||||
 | 
					        socket.on('optionsUpdated', (options) => {
 | 
				
			||||||
 | 
					            availableOptions = options;
 | 
				
			||||||
 | 
					            renderOptions();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initial load
 | 
				
			||||||
 | 
					        fetch('/api/options')
 | 
				
			||||||
 | 
					            .then(response => response.json())
 | 
				
			||||||
 | 
					            .then(options => {
 | 
				
			||||||
 | 
					                availableOptions = options;
 | 
				
			||||||
 | 
					                renderOptions();
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            .catch(error => {
 | 
				
			||||||
 | 
					                console.error('Error loading options:', error);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					    </script>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										258
									
								
								arbetsgrupp-live/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								arbetsgrupp-live/server.js
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,258 @@
 | 
				
			||||||
 | 
					const express = require('express');
 | 
				
			||||||
 | 
					const http = require('http');
 | 
				
			||||||
 | 
					const socketIo = require('socket.io');
 | 
				
			||||||
 | 
					const Database = require('better-sqlite3');
 | 
				
			||||||
 | 
					const QRCode = require('qrcode');
 | 
				
			||||||
 | 
					const path = require('path');
 | 
				
			||||||
 | 
					const cors = require('cors');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const app = express();
 | 
				
			||||||
 | 
					const server = http.createServer(app);
 | 
				
			||||||
 | 
					const io = socketIo(server, {
 | 
				
			||||||
 | 
					  cors: {
 | 
				
			||||||
 | 
					    origin: "*",
 | 
				
			||||||
 | 
					    methods: ["GET", "POST"]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const PORT = process.env.PORT || 3000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Middleware
 | 
				
			||||||
 | 
					app.use(cors());
 | 
				
			||||||
 | 
					app.use(express.json());
 | 
				
			||||||
 | 
					app.use(express.static('public'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initialize better-sqlite3 database
 | 
				
			||||||
 | 
					const db = new Database('./data/responses.db');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Configure database for better performance
 | 
				
			||||||
 | 
					db.pragma('journal_mode = WAL');
 | 
				
			||||||
 | 
					db.pragma('synchronous = NORMAL');
 | 
				
			||||||
 | 
					db.pragma('cache_size = 1000000');
 | 
				
			||||||
 | 
					db.pragma('temp_store = memory');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initialize database tables
 | 
				
			||||||
 | 
					try {
 | 
				
			||||||
 | 
					  // Create responses table
 | 
				
			||||||
 | 
					  db.exec(`CREATE TABLE IF NOT EXISTS responses (
 | 
				
			||||||
 | 
					    id INTEGER PRIMARY KEY AUTOINCREMENT,
 | 
				
			||||||
 | 
					    participant_name TEXT NOT NULL,
 | 
				
			||||||
 | 
					    response TEXT NOT NULL,
 | 
				
			||||||
 | 
					    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
 | 
				
			||||||
 | 
					  )`);
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Create options table for dynamic options
 | 
				
			||||||
 | 
					  db.exec(`CREATE TABLE IF NOT EXISTS options (
 | 
				
			||||||
 | 
					    id INTEGER PRIMARY KEY AUTOINCREMENT,
 | 
				
			||||||
 | 
					    option_text TEXT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					    is_predefined BOOLEAN DEFAULT FALSE,
 | 
				
			||||||
 | 
					    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 | 
				
			||||||
 | 
					  )`);
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Insert predefined options
 | 
				
			||||||
 | 
					  const predefinedOptions = ['Landvetter', 'Backamo', 'Borås', 'Trollhättan'];
 | 
				
			||||||
 | 
					  const insertOption = db.prepare("INSERT OR IGNORE INTO options (option_text, is_predefined) VALUES (?, 1)");
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  predefinedOptions.forEach(option => {
 | 
				
			||||||
 | 
					    insertOption.run(option);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					} catch (error) {
 | 
				
			||||||
 | 
					  console.error('Database initialization error:', error);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Prepared statements for better performance
 | 
				
			||||||
 | 
					const getOptionsStmt = db.prepare("SELECT option_text FROM options ORDER BY is_predefined DESC, created_at ASC");
 | 
				
			||||||
 | 
					const getResponseCountsStmt = db.prepare(`
 | 
				
			||||||
 | 
					  SELECT response, COUNT(*) as count 
 | 
				
			||||||
 | 
					  FROM responses 
 | 
				
			||||||
 | 
					  GROUP BY response 
 | 
				
			||||||
 | 
					  ORDER BY response ASC
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					const getAllResponsesStmt = db.prepare(`
 | 
				
			||||||
 | 
					  SELECT participant_name, response, timestamp 
 | 
				
			||||||
 | 
					  FROM responses 
 | 
				
			||||||
 | 
					  ORDER BY timestamp DESC
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					const insertResponseStmt = db.prepare("INSERT INTO responses (participant_name, response) VALUES (?, ?)");
 | 
				
			||||||
 | 
					const insertOptionStmt = db.prepare("INSERT OR IGNORE INTO options (option_text) VALUES (?)");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Get all available options
 | 
				
			||||||
 | 
					function getOptions() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    return getOptionsStmt.all().map(row => row.option_text);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error getting options:', error);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Get response counts
 | 
				
			||||||
 | 
					function getResponseCounts() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    return getResponseCountsStmt.all();
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error getting response counts:', error);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Get all responses with participant names
 | 
				
			||||||
 | 
					function getAllResponses() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    return getAllResponsesStmt.all();
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error getting all responses:', error);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Routes
 | 
				
			||||||
 | 
					app.get('/', (req, res) => {
 | 
				
			||||||
 | 
					  res.sendFile(path.join(__dirname, 'public', 'index.html'));
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get('/admin', (req, res) => {
 | 
				
			||||||
 | 
					  res.sendFile(path.join(__dirname, 'public', 'admin.html'));
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get('/api/options', (req, res) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const options = getOptions();
 | 
				
			||||||
 | 
					    res.json(options);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('API error - options:', error);
 | 
				
			||||||
 | 
					    res.status(500).json({ error: 'Failed to fetch options' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get('/api/results', (req, res) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const results = getResponseCounts();
 | 
				
			||||||
 | 
					    res.json(results);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('API error - results:', error);
 | 
				
			||||||
 | 
					    res.status(500).json({ error: 'Failed to fetch results' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get('/api/all-responses', (req, res) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const responses = getAllResponses();
 | 
				
			||||||
 | 
					    res.json(responses);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('API error - all responses:', error);
 | 
				
			||||||
 | 
					    res.status(500).json({ error: 'Failed to fetch responses' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.post('/api/submit', (req, res) => {
 | 
				
			||||||
 | 
					  const { responses, participantName } = req.body;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (!responses || !Array.isArray(responses) || responses.length === 0) {
 | 
				
			||||||
 | 
					    return res.status(400).json({ error: 'Invalid responses' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!participantName || typeof participantName !== 'string' || participantName.trim().length === 0) {
 | 
				
			||||||
 | 
					    return res.status(400).json({ error: 'Participant name is required' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const trimmedName = participantName.trim();
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Use a transaction for better performance and data integrity
 | 
				
			||||||
 | 
					    const insertTransaction = db.transaction((responses, participantName) => {
 | 
				
			||||||
 | 
					      // Insert all responses
 | 
				
			||||||
 | 
					      responses.forEach(response => {
 | 
				
			||||||
 | 
					        insertResponseStmt.run(participantName, response);
 | 
				
			||||||
 | 
					        // Add new custom options to options table
 | 
				
			||||||
 | 
					        insertOptionStmt.run(response);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    insertTransaction(responses, trimmedName);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Emit updated data to all clients
 | 
				
			||||||
 | 
					    const options = getOptions();
 | 
				
			||||||
 | 
					    io.emit('optionsUpdated', options);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const results = getResponseCounts();
 | 
				
			||||||
 | 
					    io.emit('resultsUpdated', results);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const allResponses = getAllResponses();
 | 
				
			||||||
 | 
					    io.emit('allResponsesUpdated', allResponses);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    res.json({ success: true });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error submitting response:', error);
 | 
				
			||||||
 | 
					    res.status(500).json({ error: 'Failed to submit response' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get('/api/qr', async (req, res) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const url = `https://aeroklubben.hostux.fr`;
 | 
				
			||||||
 | 
					    const qrCode = await QRCode.toDataURL(url);
 | 
				
			||||||
 | 
					    res.json({ qrCode });
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    res.status(500).json({ error: 'Failed to generate QR code' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Socket.IO connection handling
 | 
				
			||||||
 | 
					io.on('connection', (socket) => {
 | 
				
			||||||
 | 
					  console.log('Client connected');
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Send current options and results to new client
 | 
				
			||||||
 | 
					    const options = getOptions();
 | 
				
			||||||
 | 
					    socket.emit('optionsUpdated', options);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const results = getResponseCounts();
 | 
				
			||||||
 | 
					    socket.emit('resultsUpdated', results);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const allResponses = getAllResponses();
 | 
				
			||||||
 | 
					    socket.emit('allResponsesUpdated', allResponses);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error sending initial data to client:', error);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  socket.on('disconnect', () => {
 | 
				
			||||||
 | 
					    console.log('Client disconnected');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Start server
 | 
				
			||||||
 | 
					server.listen(PORT, () => {
 | 
				
			||||||
 | 
					  console.log(`Server running on port ${PORT}`);
 | 
				
			||||||
 | 
					  console.log(`Form: https://aeroklubben.hostux.fr`);
 | 
				
			||||||
 | 
					  console.log(`Admin: https://aeroklubben.hostux.fr/admin`);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Graceful shutdown
 | 
				
			||||||
 | 
					process.on('SIGINT', () => {
 | 
				
			||||||
 | 
					  console.log('Shutting down gracefully...');
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    db.close();
 | 
				
			||||||
 | 
					    console.log('Database connection closed.');
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error closing database:', error);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  server.close(() => {
 | 
				
			||||||
 | 
					    console.log('Server closed.');
 | 
				
			||||||
 | 
					    process.exit(0);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					process.on('SIGTERM', () => {
 | 
				
			||||||
 | 
					  console.log('Received SIGTERM, shutting down gracefully...');
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    db.close();
 | 
				
			||||||
 | 
					    console.log('Database connection closed.');
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error closing database:', error);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  server.close(() => {
 | 
				
			||||||
 | 
					    console.log('Server closed.');
 | 
				
			||||||
 | 
					    process.exit(0);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
		Loading…
	
		Reference in a new issue