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