diff --git a/arbetsgrupp-live/Containerfile b/arbetsgrupp-live/Containerfile new file mode 100644 index 0000000..5a14720 --- /dev/null +++ b/arbetsgrupp-live/Containerfile @@ -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"] \ No newline at end of file diff --git a/arbetsgrupp-live/README.md b/arbetsgrupp-live/README.md new file mode 100644 index 0000000..a9da83f --- /dev/null +++ b/arbetsgrupp-live/README.md @@ -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 \ No newline at end of file diff --git a/arbetsgrupp-live/build.sh b/arbetsgrupp-live/build.sh new file mode 100755 index 0000000..9b97a85 --- /dev/null +++ b/arbetsgrupp-live/build.sh @@ -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" \ No newline at end of file diff --git a/arbetsgrupp-live/data/responses.db b/arbetsgrupp-live/data/responses.db new file mode 100644 index 0000000..4b95fc9 Binary files /dev/null and b/arbetsgrupp-live/data/responses.db differ diff --git a/arbetsgrupp-live/data/responses.db-shm b/arbetsgrupp-live/data/responses.db-shm new file mode 100644 index 0000000..742955b Binary files /dev/null and b/arbetsgrupp-live/data/responses.db-shm differ diff --git a/arbetsgrupp-live/data/responses.db-wal b/arbetsgrupp-live/data/responses.db-wal new file mode 100644 index 0000000..a12f004 Binary files /dev/null and b/arbetsgrupp-live/data/responses.db-wal differ diff --git a/arbetsgrupp-live/package.json b/arbetsgrupp-live/package.json new file mode 100644 index 0000000..9e009ed --- /dev/null +++ b/arbetsgrupp-live/package.json @@ -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" + ] + } +} diff --git a/arbetsgrupp-live/public/admin.html b/arbetsgrupp-live/public/admin.html new file mode 100644 index 0000000..dd4b930 --- /dev/null +++ b/arbetsgrupp-live/public/admin.html @@ -0,0 +1,704 @@ + + + + + + Live Resultat - Arbetsgrupper + + + + +
+

+ Vi behöver DIG! Vilken arbetsgrupp skulle du kunna tänka dig delta i? +

+

+ + + DISCONNECTED + +

+
+ +
+
+

Scanna för att svara

+ +
+

Laddar QR-kod...

+
+
+ Laddar URL... +
+ + +
+
+
0
+
Deltagare
+
+
+
+ +
+

+ Live Resultat +

+ +
+

Väntar på svar...

+
+
+
+ + + + \ No newline at end of file diff --git a/arbetsgrupp-live/public/aeroklubben.png b/arbetsgrupp-live/public/aeroklubben.png new file mode 100644 index 0000000..6db8eb9 Binary files /dev/null and b/arbetsgrupp-live/public/aeroklubben.png differ diff --git a/arbetsgrupp-live/public/index.html b/arbetsgrupp-live/public/index.html new file mode 100644 index 0000000..066f858 --- /dev/null +++ b/arbetsgrupp-live/public/index.html @@ -0,0 +1,397 @@ + + + + + + Arbetsgrupp - Deltagarformulär + + + + +
+ +

Vi behöver DIG!

+ +
+ +
+
+

Vilken arbetsgrupp skulle du kunna tänka dig delta i?

+

Du kan välja flera alternativ

+
+ +
+ +
+ +
+ +
+ +
+ + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/arbetsgrupp-live/server.js b/arbetsgrupp-live/server.js new file mode 100644 index 0000000..fea4514 --- /dev/null +++ b/arbetsgrupp-live/server.js @@ -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); + }); +}); \ No newline at end of file