mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 11:07:14 +01:00
✅ Full-stack narrowcasting platform implementation ✅ Real-time WebSocket communication for instant updates ✅ Zone-specific content distribution (reception, restaurant, skislope, lockers, shop) ✅ Professional admin dashboard with content management interface ✅ Beautiful client display with winter/snow theme matching SnowWorld branding ✅ Comprehensive technical documentation and test suite ✅ Docker deployment support with CI/CD pipeline ✅ All system tests passing successfully 🏗️ Technical Implementation: - Backend: Node.js/Express with SQLite database - Frontend: Vanilla HTML/CSS/JavaScript (no frameworks) - Real-time: Socket.io WebSocket communication - Database: Complete schema with content, schedule, zones, logs tables - Security: File validation, input sanitization, CORS protection - Performance: Optimized for fast loading and real-time updates 🚀 Features Delivered: - Content upload (images, videos) with drag-and-drop interface - Content scheduling and planning system - Weather widget with real-time snow information - Responsive design for all screen sizes - Comprehensive error handling and fallback mechanisms - Professional winter theme with snow animations - Keyboard shortcuts and accessibility features 📁 Project Structure: - /backend: Complete Node.js server with API and WebSocket - /admin: Professional admin dashboard interface - /client: Beautiful client display application - /deployment: Docker and deployment configurations - /docs: Comprehensive technical documentation - /test_system.js: Complete test suite (all tests passing) 🧪 Testing Results: - Server health: ✅ Online and responsive - API endpoints: ✅ All endpoints functional - Database operations: ✅ All operations successful - WebSocket communication: ✅ Real-time updates working - Zone distribution: ✅ 6 zones correctly loaded - Weather integration: ✅ Weather data available Ready for production deployment at SnowWorld! 🎿❄️
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
// Connection Management for SnowWorld Client
|
|
class ConnectionManager {
|
|
constructor() {
|
|
this.socket = null;
|
|
this.isConnected = false;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 10;
|
|
this.reconnectDelay = 1000;
|
|
this.heartbeatInterval = null;
|
|
this.lastPingTime = null;
|
|
this.serverUrl = 'http://localhost:3000';
|
|
this.zone = this.getZoneFromURL() || 'reception';
|
|
this.contentUpdateInterval = null;
|
|
this.lastContentUpdate = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.connect();
|
|
this.setupHeartbeat();
|
|
}
|
|
|
|
connect() {
|
|
try {
|
|
console.log('Connecting to server...');
|
|
this.updateConnectionStatus('connecting');
|
|
|
|
this.socket = io(this.serverUrl, {
|
|
transports: ['websocket', 'polling'],
|
|
timeout: 5000,
|
|
forceNew: true,
|
|
reconnection: false // We handle reconnection manually
|
|
});
|
|
|
|
this.setupEventListeners();
|
|
|
|
} catch (error) {
|
|
console.error('Connection error:', error);
|
|
this.handleConnectionError(error);
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.socket.on('connect', () => {
|
|
console.log('Connected to server');
|
|
this.isConnected = true;
|
|
this.reconnectAttempts = 0;
|
|
this.updateConnectionStatus('connected');
|
|
|
|
// Join zone-specific room
|
|
this.joinZone(this.zone);
|
|
|
|
// Request initial content
|
|
this.requestContentForZone(this.zone);
|
|
|
|
// Hide error overlay if shown
|
|
this.hideErrorOverlay();
|
|
|
|
console.log(`Joined zone: ${this.zone}`);
|
|
});
|
|
|
|
this.socket.on('disconnect', (reason) => {
|
|
console.log('Disconnected from server:', reason);
|
|
this.isConnected = false;
|
|
this.updateConnectionStatus('disconnected');
|
|
|
|
// Attempt reconnection
|
|
this.attemptReconnect();
|
|
});
|
|
|
|
this.socket.on('connect_error', (error) => {
|
|
console.error('Connection error:', error);
|
|
this.handleConnectionError(error);
|
|
});
|
|
|
|
this.socket.on('reconnect_failed', () => {
|
|
console.error('Reconnection failed');
|
|
this.handleConnectionError(new Error('Reconnection failed'));
|
|
});
|
|
|
|
// Content updates
|
|
this.socket.on('contentUpdated', (data) => {
|
|
console.log('Content update received:', data);
|
|
this.handleContentUpdate(data);
|
|
});
|
|
|
|
// Schedule updates
|
|
this.socket.on('scheduleUpdated', (data) => {
|
|
console.log('Schedule update received:', data);
|
|
this.handleScheduleUpdate(data);
|
|
});
|
|
|
|
// Zone-specific updates
|
|
this.socket.on('zoneUpdate', (data) => {
|
|
console.log('Zone update received:', data);
|
|
this.handleZoneUpdate(data);
|
|
});
|
|
|
|
// System notifications
|
|
this.socket.on('systemNotification', (data) => {
|
|
console.log('System notification:', data);
|
|
this.handleSystemNotification(data);
|
|
});
|
|
|
|
// Ping/pong for latency monitoring
|
|
this.socket.on('pong', (data) => {
|
|
if (this.lastPingTime) {
|
|
const latency = Date.now() - this.lastPingTime;
|
|
console.log(`Latency: ${latency}ms`);
|
|
this.updateLatencyDisplay(latency);
|
|
}
|
|
});
|
|
}
|
|
|
|
joinZone(zone) {
|
|
if (this.isConnected && this.socket) {
|
|
this.socket.emit('joinZone', zone);
|
|
console.log(`Requested to join zone: ${zone}`);
|
|
}
|
|
}
|
|
|
|
leaveZone(zone) {
|
|
if (this.isConnected && this.socket) {
|
|
this.socket.emit('leaveZone', zone);
|
|
console.log(`Requested to leave zone: ${zone}`);
|
|
}
|
|
}
|
|
|
|
requestContentForZone(zone) {
|
|
console.log(`Requesting content for zone: ${zone}`);
|
|
|
|
// Use HTTP API as fallback if WebSocket is not available
|
|
if (this.isConnected) {
|
|
// Request via WebSocket
|
|
this.socket.emit('requestContent', { zone: zone });
|
|
} else {
|
|
// Fallback to HTTP
|
|
this.fetchContentViaHTTP(zone);
|
|
}
|
|
}
|
|
|
|
async fetchContentViaHTTP(zone) {
|
|
try {
|
|
const response = await fetch(`http://localhost:3000/api/schedule/${zone}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const content = await response.json();
|
|
this.handleContentUpdate({
|
|
type: 'zone_content',
|
|
zone: zone,
|
|
content: content
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('HTTP content fetch error:', error);
|
|
this.handleContentError(error);
|
|
}
|
|
}
|
|
|
|
handleContentUpdate(data) {
|
|
if (data.type === 'zone_content' && data.zone === this.zone) {
|
|
console.log(`Updating content for zone ${this.zone}:`, data.content);
|
|
|
|
if (window.displayManager) {
|
|
window.displayManager.updateContent(data.content);
|
|
}
|
|
|
|
this.lastContentUpdate = new Date().toISOString();
|
|
} else if (data.type === 'content_added' || data.type === 'content_deleted') {
|
|
// Refresh content for current zone
|
|
this.requestContentForZone(this.zone);
|
|
}
|
|
}
|
|
|
|
handleScheduleUpdate(data) {
|
|
if (data.zone === this.zone) {
|
|
console.log(`Schedule updated for zone ${this.zone}`);
|
|
|
|
// Refresh content for current zone
|
|
this.requestContentForZone(this.zone);
|
|
}
|
|
}
|
|
|
|
handleZoneUpdate(data) {
|
|
if (data.zone === this.zone) {
|
|
console.log(`Zone ${this.zone} updated`);
|
|
|
|
// Refresh content for current zone
|
|
this.requestContentForZone(this.zone);
|
|
}
|
|
}
|
|
|
|
handleSystemNotification(data) {
|
|
const { message, type } = data;
|
|
|
|
// Show notification on display
|
|
if (window.displayManager) {
|
|
// Could implement a notification overlay in the display manager
|
|
console.log(`System notification: ${message} (${type})`);
|
|
}
|
|
}
|
|
|
|
handleConnectionError(error) {
|
|
console.error('Connection error:', error);
|
|
this.isConnected = false;
|
|
this.updateConnectionStatus('error');
|
|
|
|
// Show error overlay
|
|
this.showErrorOverlay('Verbindingsfout', 'Kan geen verbinding maken met de server');
|
|
|
|
// Attempt reconnection
|
|
this.attemptReconnect();
|
|
}
|
|
|
|
handleContentError(error) {
|
|
console.error('Content error:', error);
|
|
|
|
if (window.displayManager) {
|
|
window.displayManager.handleError(error);
|
|
}
|
|
}
|
|
|
|
attemptReconnect() {
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error('Max reconnection attempts reached');
|
|
this.showErrorOverlay(
|
|
'Verbinding verbroken',
|
|
'Kan geen verbinding maken. Controleer de server en netwerk.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
console.log(`Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
|
|
|
setTimeout(() => {
|
|
if (!this.isConnected) {
|
|
this.connect();
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
setupHeartbeat() {
|
|
// Send ping every 30 seconds
|
|
this.heartbeatInterval = setInterval(() => {
|
|
this.sendPing();
|
|
}, 30000);
|
|
|
|
// Initial ping
|
|
this.sendPing();
|
|
}
|
|
|
|
sendPing() {
|
|
if (this.isConnected && this.socket) {
|
|
this.lastPingTime = Date.now();
|
|
this.socket.emit('ping');
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(status) {
|
|
const statusElement = document.getElementById('connectionStatus');
|
|
if (!statusElement) return;
|
|
|
|
const statusDot = statusElement.querySelector('.status-dot');
|
|
const statusText = statusElement.querySelector('.status-text');
|
|
|
|
switch (status) {
|
|
case 'connected':
|
|
statusDot.className = 'status-dot';
|
|
statusText.textContent = 'Verbonden';
|
|
break;
|
|
case 'connecting':
|
|
statusDot.className = 'status-dot connecting';
|
|
statusText.textContent = 'Verbinden...';
|
|
break;
|
|
case 'disconnected':
|
|
statusDot.className = 'status-dot disconnected';
|
|
statusText.textContent = 'Verbroken';
|
|
break;
|
|
case 'error':
|
|
statusDot.className = 'status-dot disconnected';
|
|
statusText.textContent = 'Fout';
|
|
break;
|
|
}
|
|
}
|
|
|
|
updateLatencyDisplay(latency) {
|
|
// Could add latency display to UI if needed
|
|
console.log(`Connection latency: ${latency}ms`);
|
|
|
|
// Show warning if latency is high
|
|
if (latency > 1000) {
|
|
console.warn('High latency detected:', latency + 'ms');
|
|
}
|
|
}
|
|
|
|
showErrorOverlay(title, message) {
|
|
const overlay = document.getElementById('errorOverlay');
|
|
if (!overlay) return;
|
|
|
|
document.getElementById('errorMessage').textContent = message;
|
|
overlay.classList.add('active');
|
|
|
|
// Add retry button functionality
|
|
const retryButton = document.getElementById('retryButton');
|
|
if (retryButton) {
|
|
retryButton.onclick = () => {
|
|
this.hideErrorOverlay();
|
|
this.reconnect();
|
|
};
|
|
}
|
|
}
|
|
|
|
hideErrorOverlay() {
|
|
const overlay = document.getElementById('errorOverlay');
|
|
if (overlay) {
|
|
overlay.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
getZoneFromURL() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
return urlParams.get('zone');
|
|
}
|
|
|
|
setZone(zone) {
|
|
if (this.zone !== zone) {
|
|
console.log(`Zone changed from ${this.zone} to ${zone}`);
|
|
|
|
// Leave current zone
|
|
this.leaveZone(this.zone);
|
|
|
|
// Update zone
|
|
this.zone = zone;
|
|
|
|
// Join new zone
|
|
this.joinZone(zone);
|
|
|
|
// Request content for new zone
|
|
this.requestContentForZone(zone);
|
|
|
|
// Update display
|
|
if (window.displayManager) {
|
|
window.displayManager.setZone(zone);
|
|
}
|
|
}
|
|
}
|
|
|
|
reconnect() {
|
|
console.log('Manually reconnecting...');
|
|
this.disconnect();
|
|
this.reconnectAttempts = 0;
|
|
this.connect();
|
|
}
|
|
|
|
disconnect() {
|
|
console.log('Disconnecting from server...');
|
|
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
}
|
|
|
|
if (this.socket) {
|
|
this.socket.disconnect();
|
|
}
|
|
|
|
this.isConnected = false;
|
|
this.updateConnectionStatus('disconnected');
|
|
}
|
|
|
|
// Get connection status
|
|
getStatus() {
|
|
return {
|
|
connected: this.isConnected,
|
|
zone: this.zone,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
lastContentUpdate: this.lastContentUpdate,
|
|
socketId: this.socket?.id || null
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create global connection manager instance
|
|
window.connectionManager = new ConnectionManager(); |