🎿 Complete SnowWorld Narrowcasting System - MBO Challenge 18

 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! 🎿❄️
This commit is contained in:
Alvin-Zilverstand
2026-01-19 10:02:11 +01:00
commit 8e446a1339
35 changed files with 15110 additions and 0 deletions

628
client/js/app.js Normal file
View File

@@ -0,0 +1,628 @@
// Main Application File for SnowWorld Client Display
// Application configuration
const AppConfig = {
SERVER_URL: 'http://localhost:3000',
API_BASE_URL: 'http://localhost:3000/api',
DEFAULT_ZONE: 'reception',
REFRESH_INTERVAL: 60000, // 1 minute
ERROR_RETRY_DELAY: 5000, // 5 seconds
MAX_ERROR_RETRIES: 3,
LOADING_TIMEOUT: 10000, // 10 seconds
WEATHER_UPDATE_INTERVAL: 300000, // 5 minutes
TIME_UPDATE_INTERVAL: 1000, // 1 second
CONTENT_PRELOAD_TIME: 2000, // 2 seconds before content expires
SNOW_ANIMATION_COUNT: 8
};
// Main Application Class
class SnowWorldClientApp {
constructor() {
this.config = AppConfig;
this.isInitialized = false;
this.zone = this.getZoneFromURL() || this.config.DEFAULT_ZONE;
this.errorCount = 0;
this.loadingTimeout = null;
this.init();
}
async init() {
try {
console.log('🎿 Initializing SnowWorld Client Display...');
console.log(`📍 Zone: ${this.zone}`);
// Show loading screen
this.showLoadingScreen();
// Wait for dependencies
await this.waitForDependencies();
// Initialize components
this.setupGlobalErrorHandling();
this.setupKeyboardShortcuts();
this.setupEventListeners();
// Initialize managers
await this.initializeManagers();
// Start application
this.startApplication();
this.isInitialized = true;
console.log('✅ SnowWorld Client Display initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize application:', error);
this.handleInitializationError(error);
}
}
async waitForDependencies() {
const maxWaitTime = 15000; // 15 seconds
const checkInterval = 200; // 200ms
let elapsedTime = 0;
return new Promise((resolve, reject) => {
const checkDependencies = () => {
const required = [
window.displayManager,
window.connectionManager,
window.weatherManager
];
if (required.every(dep => dep)) {
resolve();
} else if (elapsedTime >= maxWaitTime) {
const missing = required.filter(dep => !dep).map((_, i) =>
['displayManager', 'connectionManager', 'weatherManager'][i]
);
reject(new Error(`Dependencies timeout - missing: ${missing.join(', ')}`));
} else {
elapsedTime += checkInterval;
setTimeout(checkDependencies, checkInterval);
}
};
checkDependencies();
});
}
setupGlobalErrorHandling() {
// Handle JavaScript errors
window.addEventListener('error', (event) => {
console.error('Global error:', event.error);
this.handleError(event.error);
});
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
this.handleError(event.reason);
});
// Handle network errors
window.addEventListener('offline', () => {
console.warn('Network offline detected');
this.handleNetworkError('offline');
});
window.addEventListener('online', () => {
console.log('Network online detected');
this.handleNetworkError('online');
});
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Prevent default for F keys to avoid browser interference
if (e.key.startsWith('F')) {
e.preventDefault();
}
switch (e.key) {
case 'F1':
// Show help/info
this.showSystemInfo();
break;
case 'F5':
// Refresh content
e.preventDefault();
this.refreshContent();
break;
case 'F11':
// Toggle fullscreen (handled by browser)
break;
case 'Escape':
// Exit fullscreen or show zone selector
e.preventDefault();
this.showZoneSelector();
break;
case 'r':
if (e.ctrlKey) {
e.preventDefault();
this.refreshContent();
}
break;
case 'z':
if (e.ctrlKey) {
e.preventDefault();
this.showZoneSelector();
}
break;
}
});
}
setupEventListeners() {
// Handle visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('📱 Tab hidden - pausing updates');
this.pauseUpdates();
} else {
console.log('📱 Tab visible - resuming updates');
this.resumeUpdates();
}
});
// Handle window focus/blur for better performance
window.addEventListener('blur', () => {
console.log('🪟 Window blurred - reducing update frequency');
this.reduceUpdateFrequency();
});
window.addEventListener('focus', () => {
console.log('🪟 Window focused - restoring update frequency');
this.restoreUpdateFrequency();
});
// Handle beforeunload
window.addEventListener('beforeunload', () => {
this.cleanup();
});
// Handle resize for responsive adjustments
window.addEventListener('resize', () => {
this.handleResize();
});
}
async initializeManagers() {
console.log('🔧 Initializing managers...');
// Set up inter-manager communication
if (window.connectionManager) {
window.connectionManager.zone = this.zone;
}
if (window.displayManager) {
window.displayManager.zone = this.zone;
window.displayManager.updateZoneDisplay();
}
console.log('✅ Managers initialized');
}
startApplication() {
console.log('🚀 Starting application...');
// Hide loading screen after a short delay
this.loadingTimeout = setTimeout(() => {
this.hideLoadingScreen();
}, 2000);
// Request initial content
this.requestInitialContent();
// Start periodic refresh
this.startPeriodicRefresh();
console.log('🎯 Application started successfully');
}
showLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
loadingScreen.classList.add('active');
loadingScreen.style.display = 'flex';
// Simulate loading progress
this.simulateLoadingProgress();
}
}
hideLoadingScreen() {
if (this.loadingTimeout) {
clearTimeout(this.loadingTimeout);
}
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
loadingScreen.classList.add('hidden');
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
}
}
simulateLoadingProgress() {
const progressBar = document.querySelector('.loading-progress');
if (!progressBar) return;
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress >= 90) {
progress = 90;
clearInterval(interval);
}
progressBar.style.width = `${progress}%`;
}, 200);
// Complete progress when ready
setTimeout(() => {
clearInterval(interval);
progressBar.style.width = '100%';
}, 1500);
}
requestInitialContent() {
console.log(`📺 Requesting initial content for zone: ${this.zone}`);
if (window.connectionManager) {
window.connectionManager.requestContentForZone(this.zone);
} else {
// Fallback: show placeholder
if (window.displayManager) {
window.displayManager.showPlaceholder();
}
}
}
startPeriodicRefresh() {
// Refresh content every minute
setInterval(() => {
this.refreshContent();
}, this.config.REFRESH_INTERVAL);
console.log(`🔄 Periodic refresh started with interval: ${this.config.REFRESH_INTERVAL}ms`);
}
refreshContent() {
console.log('🔄 Refreshing content...');
if (window.connectionManager) {
window.connectionManager.requestContentForZone(this.zone);
}
}
showSystemInfo() {
const status = {
zone: this.zone,
connection: window.connectionManager?.getStatus() || 'Not available',
display: window.displayManager?.getStatus() || 'Not available',
weather: window.weatherManager?.getCurrentWeather() || 'Not available',
timestamp: new Date().toISOString()
};
console.log('📊 System Info:', status);
// Could implement a visual system info overlay
alert(`SnowWorld Display System Info:\n\n` +
`Zone: ${status.zone}\n` +
`Connection: ${status.connection.connected ? 'Connected' : 'Disconnected'}\n` +
`Display: ${status.display.isPlaying ? 'Playing' : 'Stopped'}\n` +
`Weather: ${window.weatherManager?.getWeatherSummary() || 'N/A'}\n` +
`Time: ${new Date().toLocaleString('nl-NL')}`);
}
showZoneSelector() {
const modal = document.getElementById('zoneModal');
if (modal) {
this.populateZoneSelector();
modal.classList.add('active');
}
}
populateZoneSelector() {
const optionsContainer = document.getElementById('zoneOptions');
if (!optionsContainer) return;
const zones = [
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', icon: 'fa-door-open' },
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', icon: 'fa-utensils' },
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', icon: 'fa-skiing' },
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', icon: 'fa-locker' },
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', icon: 'fa-shopping-bag' }
];
optionsContainer.innerHTML = zones.map(zone => `
<div class="zone-option" data-zone="${zone.id}">
<div class="zone-option-icon">
<i class="fas ${zone.icon}"></i>
</div>
<div class="zone-option-name">${zone.name}</div>
<div class="zone-option-description">${zone.description}</div>
</div>
`).join('');
// Add click handlers
optionsContainer.querySelectorAll('.zone-option').forEach(option => {
option.addEventListener('click', () => {
const selectedZone = option.dataset.zone;
this.changeZone(selectedZone);
this.hideZoneSelector();
});
});
}
hideZoneSelector() {
const modal = document.getElementById('zoneModal');
if (modal) {
modal.classList.remove('active');
}
}
changeZone(newZone) {
if (this.zone !== newZone) {
console.log(`🔄 Changing zone from ${this.zone} to ${newZone}`);
this.zone = newZone;
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set('zone', newZone);
window.history.replaceState({}, '', url);
// Update managers
if (window.connectionManager) {
window.connectionManager.setZone(newZone);
}
if (window.displayManager) {
window.displayManager.setZone(newZone);
}
console.log(`✅ Zone changed to: ${newZone}`);
}
}
getZoneFromURL() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('zone');
}
pauseUpdates() {
if (window.displayManager) {
window.displayManager.pause();
}
}
resumeUpdates() {
if (window.displayManager) {
window.displayManager.resume();
}
}
reduceUpdateFrequency() {
// Reduce update frequency when window is not focused
// This is handled automatically by the display manager pause/resume
}
restoreUpdateFrequency() {
// Restore normal update frequency when window is focused
// This is handled automatically by the display manager pause/resume
}
handleResize() {
// Handle window resize events
console.log('📐 Window resized');
// Could implement responsive adjustments here
// For now, the CSS handles responsive design
}
handleNetworkError(type) {
switch (type) {
case 'offline':
console.warn('🌐 Network offline');
if (window.displayManager) {
window.displayManager.showError();
}
break;
case 'online':
console.log('🌐 Network online');
this.refreshContent();
break;
}
}
handleError(error) {
console.error('❌ Application error:', error);
this.errorCount++;
if (this.errorCount >= this.config.MAX_ERROR_RETRIES) {
console.error('Max error retries reached');
this.showErrorOverlay('Systeemfout', 'Te veel fouten opgetreden. Herlaad de pagina.');
return;
}
// Show user-friendly error message
const userMessage = this.getUserFriendlyErrorMessage(error);
console.warn('User message:', userMessage);
// Retry after delay
setTimeout(() => {
this.refreshContent();
}, this.config.ERROR_RETRY_DELAY);
}
handleInitializationError(error) {
console.error('💥 Initialization error:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'initialization-error';
errorDiv.innerHTML = `
<div class="error-content">
<div class="error-icon">❄️</div>
<h2>SnowWorld Display</h2>
<h3>Startfout</h3>
<p>Het systeem kon niet worden geladen.</p>
<details>
<summary>Technische details</summary>
<pre>${error.message}</pre>
</details>
<button onclick="location.reload()" class="retry-button">
<i class="fas fa-redo"></i> Opnieuw proberen
</button>
</div>
`;
document.body.innerHTML = '';
document.body.appendChild(errorDiv);
// Add styles
const style = document.createElement('style');
style.textContent = `
.initialization-error {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.error-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-content h2 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.error-content h3 {
color: #ffc107;
margin-bottom: 1rem;
}
.error-content details {
margin: 1rem 0;
text-align: left;
background: rgba(0,0,0,0.2);
padding: 1rem;
border-radius: 8px;
}
.error-content pre {
font-size: 0.9rem;
overflow-x: auto;
}
.retry-button {
background: #0066cc;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 8px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
}
.retry-button:hover {
background: #0052a3;
transform: translateY(-2px);
}
`;
document.head.appendChild(style);
}
showErrorOverlay(title, message) {
const overlay = document.getElementById('errorOverlay');
if (overlay) {
document.querySelector('#errorOverlay h3').textContent = title;
document.getElementById('errorMessage').textContent = message;
overlay.classList.add('active');
}
}
getUserFriendlyErrorMessage(error) {
const errorMap = {
'NetworkError': 'Netwerkfout - controleer verbinding',
'Failed to fetch': 'Kan geen verbinding maken met server',
'timeout': 'Time-out - probeer opnieuw',
'404': 'Content niet gevonden',
'500': 'Serverfout - probeer later opnieuw'
};
const errorMessage = error.message || error.toString();
for (const [key, message] of Object.entries(errorMap)) {
if (errorMessage.toLowerCase().includes(key.toLowerCase())) {
return message;
}
}
return 'Er is een fout opgetreden';
}
cleanup() {
console.log('🧹 Cleaning up application...');
if (window.weatherManager) {
window.weatherManager.destroy();
}
if (window.connectionManager) {
window.connectionManager.disconnect();
}
if (window.displayManager) {
window.displayManager.stop();
}
this.isInitialized = false;
console.log('✅ Application cleaned up');
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('📄 DOM loaded, initializing SnowWorld Client...');
window.snowWorldClient = new SnowWorldClientApp();
});
// Handle page unload
window.addEventListener('beforeunload', () => {
if (window.snowWorldClient) {
window.snowWorldClient.cleanup();
}
});
// Global utility functions
window.SnowWorldClientUtils = {
changeZone: (zone) => window.snowWorldClient?.changeZone(zone),
refreshContent: () => window.snowWorldClient?.refreshContent(),
showSystemInfo: () => window.snowWorldClient?.showSystemInfo(),
getStatus: () => ({
zone: window.snowWorldClient?.zone,
initialized: window.snowWorldClient?.isInitialized,
connection: window.connectionManager?.getStatus(),
display: window.displayManager?.getStatus(),
weather: window.weatherManager?.getCurrentWeather()
})
};

388
client/js/connection.js Normal file
View File

@@ -0,0 +1,388 @@
// 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();

387
client/js/display.js Normal file
View File

@@ -0,0 +1,387 @@
// Display Management for SnowWorld Client
class DisplayManager {
constructor() {
this.currentContent = [];
this.currentIndex = 0;
this.contentTimer = null;
this.transitionDuration = 1000; // 1 second
this.isPlaying = false;
this.zone = this.getZoneFromURL() || 'reception';
this.init();
}
init() {
this.setupEventListeners();
this.updateZoneDisplay();
this.hideLoadingScreen();
}
setupEventListeners() {
// Handle visibility change (tab switching)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pause();
} else {
this.resume();
}
});
// Handle window focus/blur
window.addEventListener('blur', () => this.pause());
window.addEventListener('focus', () => this.resume());
// Handle errors
window.addEventListener('error', (e) => {
console.error('Display error:', e.error);
this.handleError(e.error);
});
}
getZoneFromURL() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('zone');
}
updateZoneDisplay() {
const zoneElement = document.getElementById('currentZone');
if (zoneElement) {
zoneElement.textContent = this.getZoneDisplayName(this.zone);
}
}
getZoneDisplayName(zoneId) {
const zoneNames = {
'reception': 'Receptie',
'restaurant': 'Restaurant',
'skislope': 'Skibaan',
'lockers': 'Kluisjes',
'shop': 'Winkel',
'all': 'Algemeen'
};
return zoneNames[zoneId] || zoneId;
}
async loadContent(contentList) {
try {
console.log('Loading content for zone:', this.zone);
// Filter content for current zone
this.currentContent = contentList.filter(item =>
item.zone === this.zone || item.zone === 'all'
);
if (this.currentContent.length === 0) {
this.showPlaceholder();
return;
}
// Sort content by priority and creation date
this.currentContent.sort((a, b) => {
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
if (priorityA !== priorityB) return priorityB - priorityA;
return new Date(b.createdAt) - new Date(a.createdAt);
});
console.log(`Loaded ${this.currentContent.length} content items`);
// Start playback
this.startPlayback();
} catch (error) {
console.error('Error loading content:', error);
this.showError();
}
}
startPlayback() {
if (this.currentContent.length === 0) {
this.showPlaceholder();
return;
}
this.isPlaying = true;
this.currentIndex = 0;
// Show first content item
this.showContentItem(this.currentContent[0]);
// Set up automatic progression
this.scheduleNextContent();
}
showContentItem(contentItem) {
const display = document.getElementById('contentDisplay');
if (!display) return;
// Create content element
const contentElement = this.createContentElement(contentItem);
// Clear previous content with fade out
this.clearCurrentContent(() => {
display.appendChild(contentElement);
// Fade in new content
setTimeout(() => {
contentElement.classList.add('active');
}, 50);
});
}
createContentElement(contentItem) {
const element = document.createElement('div');
element.className = 'content-item';
element.dataset.contentId = contentItem.id;
switch (contentItem.type) {
case 'image':
element.innerHTML = `
<img src="${contentItem.url}" alt="${contentItem.title}">
`;
// Handle image load errors
element.querySelector('img').onerror = () => {
this.handleContentError(contentItem, 'image');
};
break;
case 'video':
element.innerHTML = `
<video autoplay muted loop>
<source src="${contentItem.url}" type="${contentItem.mimeType}">
Uw browser ondersteunt geen video tags.
</video>
`;
// Handle video errors
element.querySelector('video').onerror = () => {
this.handleContentError(contentItem, 'video');
};
break;
case 'livestream':
element.innerHTML = `
<div class="content-placeholder">
<i class="fas fa-broadcast-tower"></i>
<h3>Livestream</h3>
<p>${contentItem.title}</p>
</div>
`;
break;
default:
element.innerHTML = `
<div class="content-placeholder">
<i class="fas fa-file-alt"></i>
<h3>${contentItem.title}</h3>
<p>Type: ${contentItem.type}</p>
</div>
`;
}
return element;
}
handleContentError(contentItem, type) {
console.error(`Error loading ${type}:`, contentItem);
// Replace with error placeholder
const element = document.querySelector(`[data-content-id="${contentItem.id}"]`);
if (element) {
element.innerHTML = `
<div class="content-placeholder error">
<i class="fas fa-exclamation-triangle"></i>
<h3>Fout bij laden</h3>
<p>${type} kon niet worden geladen</p>
</div>
`;
}
}
clearCurrentContent(callback) {
const currentItems = document.querySelectorAll('.content-item');
let itemsToRemove = currentItems.length;
if (itemsToRemove === 0) {
if (callback) callback();
return;
}
currentItems.forEach(item => {
item.classList.remove('active');
item.classList.add('content-fade-out');
setTimeout(() => {
item.remove();
itemsToRemove--;
if (itemsToRemove === 0 && callback) {
callback();
}
}, this.transitionDuration);
});
}
scheduleNextContent() {
if (!this.isPlaying) return;
// Clear existing timer
if (this.contentTimer) {
clearTimeout(this.contentTimer);
}
const currentItem = this.currentContent[this.currentIndex];
const duration = (currentItem.duration || 10) * 1000; // Convert to milliseconds
this.contentTimer = setTimeout(() => {
this.nextContent();
}, duration);
}
nextContent() {
if (!this.isPlaying || this.currentContent.length === 0) return;
// Move to next content item
this.currentIndex = (this.currentIndex + 1) % this.currentContent.length;
// Show next content
this.showContentItem(this.currentContent[this.currentIndex]);
// Schedule next content
this.scheduleNextContent();
}
previousContent() {
if (!this.isPlaying || this.currentContent.length === 0) return;
// Move to previous content item
this.currentIndex = (this.currentIndex - 1 + this.currentContent.length) % this.currentContent.length;
// Show previous content
this.showContentItem(this.currentContent[this.currentIndex]);
// Schedule next content
this.scheduleNextContent();
}
showPlaceholder() {
const display = document.getElementById('contentDisplay');
if (!display) return;
this.clearCurrentContent(() => {
const placeholder = document.createElement('div');
placeholder.className = 'content-item active';
placeholder.innerHTML = `
<div class="content-placeholder">
<i class="fas fa-snowflake"></i>
<h3>Welkom bij SnowWorld</h3>
<p>Er is momenteel geen content beschikbaar voor deze zone.</p>
</div>
`;
display.appendChild(placeholder);
});
}
showError() {
const display = document.getElementById('contentDisplay');
if (!display) return;
this.clearCurrentContent(() => {
const error = document.createElement('div');
error.className = 'content-item active';
error.innerHTML = `
<div class="content-placeholder error">
<i class="fas fa-exclamation-triangle"></i>
<h3>Fout bij het laden van content</h3>
<p>Er is een fout opgetreden. Probeer het opnieuw.</p>
</div>
`;
display.appendChild(error);
});
}
pause() {
this.isPlaying = false;
if (this.contentTimer) {
clearTimeout(this.contentTimer);
}
console.log('Display paused');
}
resume() {
if (!this.isPlaying && this.currentContent.length > 0) {
this.isPlaying = true;
this.scheduleNextContent();
console.log('Display resumed');
}
}
stop() {
this.isPlaying = false;
if (this.contentTimer) {
clearTimeout(this.contentTimer);
}
this.clearCurrentContent();
console.log('Display stopped');
}
updateContent(newContent) {
console.log('Updating content...');
// Stop current playback
this.stop();
// Load new content
this.loadContent(newContent);
}
setZone(zone) {
if (this.zone !== zone) {
console.log(`Zone changed from ${this.zone} to ${zone}`);
this.zone = zone;
this.updateZoneDisplay();
// Request new content for this zone
if (window.connectionManager) {
window.connectionManager.requestContentForZone(zone);
}
}
}
hideLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
loadingScreen.classList.add('hidden');
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
}
}
handleError(error) {
console.error('Display error:', error);
this.showError();
// Show error overlay
const errorOverlay = document.getElementById('errorOverlay');
if (errorOverlay) {
document.getElementById('errorMessage').textContent =
'Kan geen content laden. Controleer de verbinding.';
errorOverlay.classList.add('active');
}
}
// Get current status
getStatus() {
return {
isPlaying: this.isPlaying,
currentZone: this.zone,
contentCount: this.currentContent.length,
currentIndex: this.currentIndex,
currentContent: this.currentContent[this.currentIndex] || null
};
}
}
// Create global display manager instance
window.displayManager = new DisplayManager();

287
client/js/weather.js Normal file
View File

@@ -0,0 +1,287 @@
// Weather Widget Management for SnowWorld Client
class WeatherManager {
constructor() {
this.weatherData = null;
this.updateInterval = null;
this.lastUpdate = null;
this.updateFrequency = 5 * 60 * 1000; // 5 minutes
this.init();
}
init() {
this.loadWeatherData();
this.startAutoUpdate();
this.updateTimeDisplay();
this.startTimeUpdate();
}
async loadWeatherData() {
try {
// Try to get weather data from server
const response = await fetch('http://localhost:3000/api/weather');
if (response.ok) {
this.weatherData = await response.json();
this.lastUpdate = new Date().toISOString();
this.updateWeatherDisplay();
console.log('Weather data loaded:', this.weatherData);
} else {
throw new Error('Failed to fetch weather data');
}
} catch (error) {
console.error('Error loading weather data:', error);
this.useFallbackWeatherData();
}
}
useFallbackWeatherData() {
// Fallback to mock weather data
this.weatherData = {
temperature: -5,
snowCondition: 'Frisse sneeuw',
slopeCondition: 'Perfect',
humidity: 65,
windSpeed: 8,
lastUpdated: new Date().toISOString()
};
this.lastUpdate = new Date().toISOString();
this.updateWeatherDisplay();
console.log('Using fallback weather data');
}
updateWeatherDisplay() {
if (!this.weatherData) return;
const elements = {
temperature: document.getElementById('temperature'),
snowCondition: document.getElementById('snowCondition'),
humidity: document.getElementById('humidity'),
windSpeed: document.getElementById('windSpeed')
};
// Update temperature
if (elements.temperature) {
elements.temperature.textContent = this.weatherData.temperature;
}
// Update snow condition
if (elements.snowCondition) {
elements.snowCondition.textContent = this.weatherData.snowCondition;
}
// Update humidity
if (elements.humidity) {
elements.humidity.textContent = `${this.weatherData.humidity}%`;
}
// Update wind speed
if (elements.windSpeed) {
elements.windSpeed.textContent = this.weatherData.windSpeed;
}
// Update weather condition icon
this.updateWeatherIcon();
}
updateWeatherIcon() {
const condition = this.weatherData.snowCondition.toLowerCase();
const iconElement = document.querySelector('.weather-condition i');
if (!iconElement) return;
let iconClass = 'fa-snowflake';
if (condition.includes('fris')) {
iconClass = 'fa-snowflake';
} else if (condition.includes('poeder')) {
iconClass = 'fa-skiing';
} else if (condition.includes('nat')) {
iconClass = 'fa-tint';
} else if (condition.includes('ijzig')) {
iconClass = 'fa-icicles';
} else if (condition.includes('storm')) {
iconClass = 'fa-wind';
}
iconElement.className = `fas ${iconClass}`;
}
updateTimeDisplay() {
const now = new Date();
// Update time
const timeElement = document.getElementById('currentTime');
if (timeElement) {
timeElement.textContent = now.toLocaleTimeString('nl-NL', {
hour: '2-digit',
minute: '2-digit'
});
}
// Update date
const dateElement = document.getElementById('currentDate');
if (dateElement) {
dateElement.textContent = now.toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
}
startTimeUpdate() {
// Update time every second
setInterval(() => {
this.updateTimeDisplay();
}, 1000);
}
startAutoUpdate() {
// Update weather every 5 minutes
this.updateInterval = setInterval(() => {
this.loadWeatherData();
}, this.updateFrequency);
console.log(`Weather auto-update started with frequency: ${this.updateFrequency}ms`);
}
stopAutoUpdate() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
console.log('Weather auto-update stopped');
}
}
// Simulate weather changes for demo purposes
simulateWeatherChange() {
const conditions = [
{ temperature: -8, snowCondition: 'Poedersneeuw', humidity: 45, windSpeed: 12 },
{ temperature: -3, snowCondition: 'Natte sneeuw', humidity: 85, windSpeed: 6 },
{ temperature: -12, snowCondition: 'IJzige sneeuw', humidity: 35, windSpeed: 15 },
{ temperature: -1, snowCondition: 'Koude regen', humidity: 90, windSpeed: 8 },
{ temperature: -6, snowCondition: 'Frisse sneeuw', humidity: 65, windSpeed: 8 }
];
const randomCondition = conditions[Math.floor(Math.random() * conditions.length)];
this.weatherData = {
...this.weatherData,
...randomCondition,
lastUpdated: new Date().toISOString()
};
this.updateWeatherDisplay();
console.log('Weather simulation updated:', this.weatherData);
}
// Get weather-based background gradient
getWeatherBackground() {
if (!this.weatherData) return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
const temp = this.weatherData.temperature;
const condition = this.weatherData.snowCondition.toLowerCase();
// Temperature-based gradients
if (temp <= -10) {
return 'linear-gradient(135deg, #1e3c72 0%, #2a5298 100%)'; // Very cold - dark blue
} else if (temp <= -5) {
return 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'; // Cold - light blue
} else if (temp <= 0) {
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; // Near freezing - purple
} else {
return 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)'; // Above freezing - light
}
}
// Update display background based on weather
updateBackground() {
const background = this.getWeatherBackground();
document.body.style.background = background;
// Also update the display container if it exists
const displayContainer = document.querySelector('.display-container');
if (displayContainer) {
displayContainer.style.background = background;
}
}
// Get current weather data
getCurrentWeather() {
return {
...this.weatherData,
lastUpdate: this.lastUpdate
};
}
// Get weather summary for display
getWeatherSummary() {
if (!this.weatherData) return 'Geen weersdata beschikbaar';
return `${this.weatherData.temperature}°C, ${this.weatherData.snowCondition}`;
}
// Check if weather data is stale
isWeatherDataStale() {
if (!this.lastUpdate) return true;
const lastUpdate = new Date(this.lastUpdate);
const now = new Date();
const staleThreshold = 10 * 60 * 1000; // 10 minutes
return (now - lastUpdate) > staleThreshold;
}
// Force weather update
async refreshWeather() {
console.log('Force refreshing weather data...');
await this.loadWeatherData();
}
// Set custom weather data (for testing/demo)
setWeatherData(data) {
this.weatherData = {
...this.weatherData,
...data,
lastUpdated: new Date().toISOString()
};
this.lastUpdate = new Date().toISOString();
this.updateWeatherDisplay();
this.updateBackground();
console.log('Custom weather data set:', this.weatherData);
}
// Get weather icon for condition
getWeatherIcon(condition) {
const conditionLower = condition.toLowerCase();
if (conditionLower.includes('fris')) return 'fa-snowflake';
if (conditionLower.includes('poeder')) return 'fa-skiing';
if (conditionLower.includes('nat')) return 'fa-tint';
if (conditionLower.includes('ijzig')) return 'fa-icicles';
if (conditionLower.includes('storm')) return 'fa-wind';
if (conditionLower.includes('koud')) return 'fa-temperature-low';
return 'fa-snowflake';
}
// Get temperature color based on value
getTemperatureColor(temp) {
if (temp <= -10) return '#1e3c72'; // Very cold - dark blue
if (temp <= -5) return '#4facfe'; // Cold - blue
if (temp <= 0) return '#667eea'; // Near freezing - purple
if (temp <= 5) return '#89f7fe'; // Cold - light blue
return '#66a6ff'; // Cool - light
}
// Cleanup
destroy() {
this.stopAutoUpdate();
console.log('Weather manager destroyed');
}
}
// Create global weather manager instance
window.weatherManager = new WeatherManager();