🎿 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

140
admin/js/api.js Normal file
View File

@@ -0,0 +1,140 @@
// API Service for SnowWorld Admin Dashboard
class APIService {
constructor() {
this.baseURL = 'http://localhost:3000/api';
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
try {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// Content Management
async getContent(zone = null, type = null) {
const params = new URLSearchParams();
if (zone) params.append('zone', zone);
if (type) params.append('type', type);
return this.request(`/content?${params.toString()}`);
}
async uploadContent(formData) {
return fetch(`${this.baseURL}/content/upload`, {
method: 'POST',
body: formData
}).then(response => {
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.json();
});
}
async deleteContent(contentId) {
return this.request(`/content/${contentId}`, {
method: 'DELETE'
});
}
// Schedule Management
async getSchedule(zone) {
return this.request(`/schedule/${zone}`);
}
async createSchedule(scheduleData) {
return this.request('/schedule', {
method: 'POST',
body: JSON.stringify(scheduleData)
});
}
// Zones
async getZones() {
return this.request('/zones');
}
// Weather Data
async getWeatherData() {
return this.request('/weather');
}
// Analytics
async getContentStats() {
try {
const content = await this.getContent();
const stats = {
total: content.length,
byType: {},
byZone: {}
};
content.forEach(item => {
// Count by type
stats.byType[item.type] = (stats.byType[item.type] || 0) + 1;
// Count by zone
stats.byZone[item.zone] = (stats.byZone[item.zone] || 0) + 1;
});
return stats;
} catch (error) {
console.error('Error getting content stats:', error);
throw error;
}
}
async getScheduleStats() {
try {
// This would typically be a dedicated endpoint
// For now, we'll calculate based on available data
const zones = await this.getZones();
let totalSchedules = 0;
let activeSchedules = 0;
for (const zone of zones) {
const schedule = await this.getSchedule(zone.id);
totalSchedules += schedule.length;
const now = new Date();
const active = schedule.filter(item => {
const start = new Date(item.startTime);
const end = new Date(item.endTime);
return now >= start && now <= end;
});
activeSchedules += active.length;
}
return {
total: totalSchedules,
active: activeSchedules,
upcoming: totalSchedules - activeSchedules
};
} catch (error) {
console.error('Error getting schedule stats:', error);
throw error;
}
}
}
// Create global API instance
window.api = new APIService();

367
admin/js/app.js Normal file
View File

@@ -0,0 +1,367 @@
// Main Application File for SnowWorld Admin Dashboard
// Application configuration
const AppConfig = {
API_BASE_URL: 'http://localhost:3000/api',
WS_URL: 'http://localhost:3000',
REFRESH_INTERVAL: 30000, // 30 seconds
MAX_FILE_SIZE: 50 * 1024 * 1024, // 50MB
SUPPORTED_FILE_TYPES: {
'image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'video': ['video/mp4', 'video/webm', 'video/ogg']
}
};
// Main Application Class
class SnowWorldAdminApp {
constructor() {
this.config = AppConfig;
this.isInitialized = false;
this.refreshTimer = null;
this.init();
}
async init() {
try {
console.log('Initializing SnowWorld Admin Dashboard...');
// Wait for dependencies to load
await this.waitForDependencies();
// Initialize application components
this.setupGlobalErrorHandling();
this.setupKeyboardShortcuts();
this.setupAutoRefresh();
// Initialize UI and WebSocket connections
if (window.ui) {
console.log('UI Manager loaded successfully');
}
if (window.wsManager) {
console.log('WebSocket Manager loaded successfully');
}
if (window.api) {
console.log('API Service loaded successfully');
}
this.isInitialized = true;
console.log('SnowWorld Admin Dashboard initialized successfully');
// Show welcome message
this.showWelcomeMessage();
} catch (error) {
console.error('Failed to initialize application:', error);
this.handleInitializationError(error);
}
}
async waitForDependencies() {
const maxWaitTime = 10000; // 10 seconds
const checkInterval = 100; // 100ms
let elapsedTime = 0;
return new Promise((resolve, reject) => {
const checkDependencies = () => {
if (window.ui && window.wsManager && window.api) {
resolve();
} else if (elapsedTime >= maxWaitTime) {
reject(new Error('Dependencies timeout - required services not loaded'));
} 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);
});
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + R: Refresh data
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
this.refreshData();
}
// Ctrl/Cmd + N: New content (if on content tab)
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault();
if (window.ui && window.ui.currentTab === 'content') {
window.ui.openContentModal();
}
}
// Escape: Close modals
if (e.key === 'Escape') {
window.ui?.closeModals();
}
// F5: Refresh (prevent default and use our refresh)
if (e.key === 'F5') {
e.preventDefault();
this.refreshData();
}
});
}
setupAutoRefresh() {
// Clear any existing timer
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
// Set up new timer
this.refreshTimer = setInterval(() => {
this.autoRefresh();
}, this.config.REFRESH_INTERVAL);
console.log(`Auto-refresh enabled with interval: ${this.config.REFRESH_INTERVAL}ms`);
}
autoRefresh() {
// Only refresh if connected and not in modal
if (window.wsManager?.getConnectionStatus().connected &&
!document.querySelector('.modal.active')) {
console.log('Performing auto-refresh...');
// Refresh current tab data
if (window.ui) {
window.ui.refreshData();
}
}
}
refreshData() {
if (window.ui) {
window.ui.refreshData();
}
if (window.wsManager) {
const status = window.wsManager.getConnectionStatus();
console.log('Connection status:', status);
}
}
showWelcomeMessage() {
const messages = [
'Welkom bij SnowWorld Narrowcasting Admin!',
'Systeem succesvol geladen.',
'Klaar om content te beheren.'
];
messages.forEach((message, index) => {
setTimeout(() => {
window.ui?.showToast(message, 'info');
}, index * 1000);
});
}
handleError(error) {
console.error('Application error:', error);
// Show user-friendly error message
const userMessage = this.getUserFriendlyErrorMessage(error);
window.ui?.showToast(userMessage, 'error');
// Log to server if connected
if (window.wsManager?.getConnectionStatus().connected) {
window.wsManager.sendMessage('clientError', {
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
});
}
}
handleInitializationError(error) {
console.error('Initialization error:', error);
// Create emergency error display
const errorDiv = document.createElement('div');
errorDiv.className = 'emergency-error';
errorDiv.innerHTML = `
<div class="error-content">
<h2>❄️ SnowWorld Admin Dashboard</h2>
<h3>Startfout</h3>
<p>Er is een fout opgetreden bij het laden van het systeem.</p>
<details>
<summary>Technische details</summary>
<pre>${error.message}\n${error.stack}</pre>
</details>
<button onclick="location.reload()" class="btn btn-primary">Opnieuw Proberen</button>
</div>
`;
document.body.innerHTML = '';
document.body.appendChild(errorDiv);
// Add emergency styles
const style = document.createElement('style');
style.textContent = `
.emergency-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;
z-index: 9999;
}
.error-content {
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
max-width: 600px;
text-align: center;
}
.error-content h2 {
color: #0066cc;
margin-bottom: 1rem;
}
.error-content h3 {
color: #dc3545;
margin-bottom: 1rem;
}
.error-content details {
margin: 1rem 0;
text-align: left;
}
.error-content pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
font-size: 0.8rem;
}
`;
document.head.appendChild(style);
}
getUserFriendlyErrorMessage(error) {
// Map common errors to user-friendly messages
const errorMap = {
'NetworkError': 'Netwerkfout - controleer uw internetverbinding',
'TypeError: Failed to fetch': 'Kan geen verbinding maken met de server',
'HTTP error! status: 404': 'Gevraagde gegevens niet gevonden',
'HTTP error! status: 500': 'Serverfout - probeer het later opnieuw',
'timeout': 'Time-out - het verzoek duurde te lang',
'upload': 'Upload mislukt - controleer het bestand',
'delete': 'Verwijderen mislukt - probeer het 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 - probeer het opnieuw';
}
// Utility methods
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
validateFile(file) {
if (!file) return { valid: false, error: 'Geen bestand geselecteerd' };
if (file.size > this.config.MAX_FILE_SIZE) {
return {
valid: false,
error: `Bestand te groot (max ${this.formatFileSize(this.config.MAX_FILE_SIZE)})`
};
}
const fileType = file.type;
let isValidType = false;
for (const types of Object.values(this.config.SUPPORTED_FILE_TYPES)) {
if (types.includes(fileType)) {
isValidType = true;
break;
}
}
if (!isValidType) {
return {
valid: false,
error: 'Niet-ondersteund bestandstype'
};
}
return { valid: true };
}
// Cleanup
destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
if (window.wsManager) {
window.wsManager.disconnect();
}
this.isInitialized = false;
console.log('SnowWorld Admin Dashboard destroyed');
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing application...');
window.snowWorldApp = new SnowWorldAdminApp();
});
// Handle page unload
window.addEventListener('beforeunload', () => {
if (window.snowWorldApp) {
window.snowWorldApp.destroy();
}
});
// Global utility functions
window.SnowWorldUtils = {
formatFileSize: (bytes) => window.snowWorldApp?.formatFileSize(bytes) || '0 Bytes',
formatDuration: (seconds) => window.snowWorldApp?.formatDuration(seconds) || '0s',
validateFile: (file) => window.snowWorldApp?.validateFile(file) || { valid: false, error: 'App not initialized' }
};

567
admin/js/ui.js Normal file
View File

@@ -0,0 +1,567 @@
// UI Management for SnowWorld Admin Dashboard
class UIManager {
constructor() {
this.currentTab = 'content';
this.contentCache = new Map();
this.zonesCache = null;
this.init();
}
init() {
this.setupEventListeners();
this.loadZones();
this.loadInitialData();
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Content upload
document.getElementById('addContentBtn')?.addEventListener('click', () => {
this.openContentModal();
});
document.getElementById('contentUploadForm')?.addEventListener('submit', (e) => {
e.preventDefault();
this.uploadContent();
});
// Schedule management
document.getElementById('addScheduleBtn')?.addEventListener('click', () => {
this.openScheduleModal();
});
document.getElementById('scheduleForm')?.addEventListener('submit', (e) => {
e.preventDefault();
this.createSchedule();
});
// Filters
document.getElementById('applyFilters')?.addEventListener('click', () => {
this.applyContentFilters();
});
// Modal controls
document.querySelectorAll('.close-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.closeModals();
});
});
// Refresh button
document.getElementById('refreshBtn')?.addEventListener('click', () => {
this.refreshData();
});
// File input preview
document.getElementById('contentFile')?.addEventListener('change', (e) => {
this.previewFile(e.target.files[0]);
});
}
// Tab Management
switchTab(tabName) {
// Update active tab
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabName}-tab`).classList.add('active');
this.currentTab = tabName;
this.loadTabData(tabName);
}
async loadTabData(tabName) {
try {
switch (tabName) {
case 'content':
await this.loadContent();
break;
case 'schedule':
await this.loadSchedule();
break;
case 'zones':
await this.loadZonesOverview();
break;
case 'analytics':
await this.loadAnalytics();
break;
}
} catch (error) {
console.error(`Error loading ${tabName} data:`, error);
this.showToast(`Fout bij het laden van ${tabName} data`, 'error');
}
}
// Content Management
async loadContent(zone = null, type = null) {
const cacheKey = `${zone || 'all'}-${type || 'all'}`;
if (this.contentCache.has(cacheKey)) {
this.renderContent(this.contentCache.get(cacheKey));
return;
}
const content = await api.getContent(zone, type);
this.contentCache.set(cacheKey, content);
this.renderContent(content);
}
renderContent(content) {
const grid = document.getElementById('contentGrid');
if (!grid) return;
if (content.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<i class="fas fa-photo-video fa-3x"></i>
<h3>Geen content gevonden</h3>
<p>Begin met het toevoegen van content voor uw narrowcasting systeem.</p>
</div>
`;
return;
}
grid.innerHTML = content.map(item => this.createContentCard(item)).join('');
// Add event listeners to content cards
grid.querySelectorAll('.delete-content').forEach(btn => {
btn.addEventListener('click', (e) => {
const contentId = e.target.dataset.contentId;
this.deleteContent(contentId);
});
});
}
createContentCard(item) {
const typeIcon = {
'image': 'fa-image',
'video': 'fa-video',
'livestream': 'fa-broadcast-tower'
}[item.type] || 'fa-file';
const typeLabel = {
'image': 'Afbeelding',
'video': 'Video',
'livestream': 'Livestream'
}[item.type] || 'Bestand';
return `
<div class="content-item" data-content-id="${item.id}">
<div class="content-preview ${item.type}">
${item.type === 'image' ?
`<img src="${item.url}" alt="${item.title}" onerror="this.src='https://via.placeholder.com/300x200?text=Afbeelding'">` :
`<i class="fas ${typeIcon} fa-3x"></i>`
}
</div>
<div class="content-info">
<h3 class="content-title">${item.title}</h3>
<div class="content-meta">
<span><i class="fas ${typeIcon}"></i> ${typeLabel}</span>
<span><i class="fas fa-map-marker-alt"></i> Zone: ${item.zone}</span>
<span><i class="fas fa-clock"></i> Duur: ${item.duration}s</span>
<span><i class="fas fa-calendar"></i> ${new Date(item.createdAt).toLocaleDateString('nl-NL')}</span>
</div>
<div class="content-actions">
<button class="btn btn-danger btn-small delete-content" data-content-id="${item.id}">
<i class="fas fa-trash"></i> Verwijderen
</button>
</div>
</div>
</div>
`;
}
// Modal Management
openContentModal() {
const modal = document.getElementById('contentModal');
modal.classList.add('active');
this.loadZonesSelect('contentZone');
}
openScheduleModal() {
const modal = document.getElementById('scheduleModal');
modal.classList.add('active');
this.loadContentSelect();
this.loadZonesSelect('scheduleZone');
this.setDefaultScheduleTimes();
}
closeModals() {
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.remove('active');
});
// Reset forms
document.getElementById('contentUploadForm')?.reset();
document.getElementById('scheduleForm')?.reset();
document.getElementById('fileInfo').innerHTML = '';
}
// Content Upload
previewFile(file) {
if (!file) return;
const fileInfo = document.getElementById('fileInfo');
const fileSize = (file.size / (1024 * 1024)).toFixed(2);
fileInfo.innerHTML = `
<div class="file-details">
<strong>Bestand:</strong> ${file.name}<br>
<strong>Grootte:</strong> ${fileSize} MB<br>
<strong>Type:</strong> ${file.type}
</div>
`;
// Auto-detect content type
if (file.type.startsWith('image/')) {
document.getElementById('contentType').value = 'image';
} else if (file.type.startsWith('video/')) {
document.getElementById('contentType').value = 'video';
}
}
async uploadContent() {
const form = document.getElementById('contentUploadForm');
const formData = new FormData();
const fileInput = document.getElementById('contentFile');
const title = document.getElementById('contentTitle').value;
const type = document.getElementById('contentType').value;
const zone = document.getElementById('contentZone').value;
const duration = document.getElementById('contentDuration').value;
if (!fileInput.files[0]) {
this.showToast('Selecteer een bestand', 'error');
return;
}
formData.append('content', fileInput.files[0]);
formData.append('title', title);
formData.append('type', type);
formData.append('zone', zone);
formData.append('duration', duration);
try {
this.showLoading('Bezig met uploaden...');
const result = await api.uploadContent(formData);
this.closeModals();
this.clearContentCache();
await this.loadContent();
this.showToast('Content succesvol geüpload!', 'success');
} catch (error) {
console.error('Upload error:', error);
this.showToast('Upload mislukt: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
async deleteContent(contentId) {
if (!confirm('Weet u zeker dat u deze content wilt verwijderen?')) {
return;
}
try {
this.showLoading('Bezig met verwijderen...');
await api.deleteContent(contentId);
this.clearContentCache();
await this.loadContent();
this.showToast('Content succesvol verwijderd', 'success');
} catch (error) {
console.error('Delete error:', error);
this.showToast('Verwijderen mislukt: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
// Schedule Management
async loadSchedule() {
const zoneSelect = document.getElementById('scheduleZoneSelect');
const selectedZone = zoneSelect?.value || 'reception';
try {
const schedule = await api.getSchedule(selectedZone);
this.renderSchedule(schedule);
} catch (error) {
console.error('Error loading schedule:', error);
this.showToast('Fout bij het laden van planning', 'error');
}
}
renderSchedule(schedule) {
const timeline = document.getElementById('scheduleTimeline');
if (!timeline) return;
if (schedule.length === 0) {
timeline.innerHTML = `
<div class="empty-state">
<i class="fas fa-calendar-times fa-3x"></i>
<h3>Geen actieve planning</h3>
<p>Er is momenteel geen geplande content voor deze zone.</p>
</div>
`;
return;
}
timeline.innerHTML = schedule.map(item => `
<div class="schedule-item">
<div class="schedule-time">
${new Date(item.startTime).toLocaleTimeString('nl-NL', {hour: '2-digit', minute: '2-digit'})} -
${new Date(item.endTime).toLocaleTimeString('nl-NL', {hour: '2-digit', minute: '2-digit'})}
</div>
<div class="schedule-content">
<h4>${item.title}</h4>
<p>Type: ${item.type} | Duur: ${item.duration}s</p>
</div>
</div>
`).join('');
}
async createSchedule() {
const formData = {
contentId: document.getElementById('scheduleContent').value,
zone: document.getElementById('scheduleZone').value,
startTime: document.getElementById('scheduleStart').value,
endTime: document.getElementById('scheduleEnd').value,
priority: parseInt(document.getElementById('schedulePriority').value)
};
try {
this.showLoading('Bezig met plannen...');
await api.createSchedule(formData);
this.closeModals();
await this.loadSchedule();
this.showToast('Planning succesvol aangemaakt!', 'success');
} catch (error) {
console.error('Schedule creation error:', error);
this.showToast('Planning mislukt: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
setDefaultScheduleTimes() {
const now = new Date();
const startTime = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour duration
document.getElementById('scheduleStart').value = startTime.toISOString().slice(0, 16);
document.getElementById('scheduleEnd').value = endTime.toISOString().slice(0, 16);
}
// Zones Management
async loadZones() {
if (this.zonesCache) return this.zonesCache;
try {
this.zonesCache = await api.getZones();
return this.zonesCache;
} catch (error) {
console.error('Error loading zones:', error);
return [];
}
}
async loadZonesSelect(selectId) {
const zones = await this.loadZones();
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = zones.map(zone =>
`<option value="${zone.id}">${zone.name}</option>`
).join('');
}
async loadContentSelect() {
try {
const content = await api.getContent();
const select = document.getElementById('scheduleContent');
if (!select) return;
select.innerHTML = content.map(item =>
`<option value="${item.id}">${item.title} (${item.type})</option>`
).join('');
} catch (error) {
console.error('Error loading content select:', error);
}
}
async loadZonesOverview() {
const zones = await this.loadZones();
const grid = document.getElementById('zonesGrid');
if (!grid) return;
const zoneIcons = {
'reception': 'fa-door-open',
'restaurant': 'fa-utensils',
'skislope': 'fa-skiing',
'lockers': 'fa-locker',
'shop': 'fa-shopping-bag',
'all': 'fa-globe'
};
grid.innerHTML = zones.map(zone => `
<div class="zone-card">
<div class="zone-icon">
<i class="fas ${zoneIcons[zone.id] || 'fa-map-marker-alt'} fa-3x"></i>
</div>
<h3 class="zone-name">${zone.name}</h3>
<p class="zone-description">${zone.description}</p>
</div>
`).join('');
}
// Analytics
async loadAnalytics() {
try {
const contentStats = await api.getContentStats();
const scheduleStats = await api.getScheduleStats();
const zones = await this.loadZones();
this.renderContentStats(contentStats);
this.renderScheduleStats(scheduleStats);
this.renderZoneStats(zones);
} catch (error) {
console.error('Error loading analytics:', error);
this.showToast('Fout bij het laden van analytics', 'error');
}
}
renderContentStats(stats) {
const container = document.getElementById('contentStats');
if (!container) return;
container.innerHTML = `
<div class="stat-item">
<span class="stat-label">Totaal Content</span>
<span class="stat-value">${stats.total}</span>
</div>
${Object.entries(stats.byType).map(([type, count]) => `
<div class="stat-item">
<span class="stat-label">${type.charAt(0).toUpperCase() + type.slice(1)}</span>
<span class="stat-value">${count}</span>
</div>
`).join('')}
`;
}
renderScheduleStats(stats) {
const container = document.getElementById('scheduleStats');
if (!container) return;
container.innerHTML = `
<div class="stat-item">
<span class="stat-label">Totaal Planningen</span>
<span class="stat-value">${stats.total}</span>
</div>
<div class="stat-item">
<span class="stat-label">Actief</span>
<span class="stat-value">${stats.active}</span>
</div>
<div class="stat-item">
<span class="stat-label">Aankomend</span>
<span class="stat-value">${stats.upcoming}</span>
</div>
`;
}
renderZoneStats(zones) {
const container = document.getElementById('zoneStats');
if (!container) return;
container.innerHTML = zones.map(zone => `
<div class="stat-item">
<span class="stat-label">${zone.name}</span>
<span class="stat-value">${zone.description}</span>
</div>
`).join('');
}
// Utility Methods
showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="toast-message">${message}</div>
<button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
`;
container.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 5000);
}
showLoading(message = 'Bezig...') {
const loading = document.createElement('div');
loading.id = 'globalLoading';
loading.className = 'loading-overlay';
loading.innerHTML = `
<div class="loading-content">
<div class="spinner"></div>
<p>${message}</p>
</div>
`;
document.body.appendChild(loading);
}
hideLoading() {
const loading = document.getElementById('globalLoading');
if (loading) {
loading.remove();
}
}
clearContentCache() {
this.contentCache.clear();
}
async refreshData() {
this.clearContentCache();
await this.loadTabData(this.currentTab);
this.showToast('Data ververst!', 'success');
}
async loadInitialData() {
try {
await this.loadZones();
await this.loadContent();
} catch (error) {
console.error('Error loading initial data:', error);
this.showToast('Fout bij het laden van initiële data', 'error');
}
}
applyContentFilters() {
const zone = document.getElementById('zoneFilter').value;
const type = document.getElementById('typeFilter').value;
this.loadContent(zone || null, type || null);
}
}
// Create global UI instance
window.ui = new UIManager();

240
admin/js/websocket.js Normal file
View File

@@ -0,0 +1,240 @@
// WebSocket Management for SnowWorld Admin Dashboard
class WebSocketManager {
constructor() {
this.socket = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.init();
}
init() {
this.connect();
}
connect() {
try {
this.socket = io('http://localhost:3000', {
transports: ['websocket', 'polling'],
timeout: 5000,
forceNew: true
});
this.setupEventListeners();
} catch (error) {
console.error('WebSocket connection error:', error);
this.handleConnectionError();
}
}
setupEventListeners() {
this.socket.on('connect', () => {
console.log('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
// Join admin room for global updates
this.socket.emit('joinZone', 'admin');
this.showToast('Verbonden met server', 'success');
});
this.socket.on('disconnect', () => {
console.log('WebSocket disconnected');
this.isConnected = false;
this.updateConnectionStatus(false);
// Attempt reconnection
this.attemptReconnect();
});
this.socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
this.handleConnectionError();
});
// 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);
});
}
handleContentUpdate(data) {
// Clear content cache to force refresh
if (window.ui) {
window.ui.clearContentCache();
}
// Show notification based on update type
switch (data.type) {
case 'content_added':
this.showToast(`Nieuwe content toegevoegd: ${data.content.title}`, 'info');
break;
case 'content_deleted':
this.showToast('Content verwijderd', 'warning');
break;
case 'content_updated':
this.showToast('Content bijgewerkt', 'info');
break;
}
// Refresh current view if on content tab
if (window.ui && window.ui.currentTab === 'content') {
window.ui.loadContent();
}
}
handleScheduleUpdate(data) {
// Show notification
this.showToast(`Planning bijgewerkt voor zone: ${data.zone}`, 'info');
// Refresh schedule view if currently viewing this zone
const currentZone = document.getElementById('scheduleZoneSelect')?.value;
if (window.ui && window.ui.currentTab === 'schedule' && currentZone === data.zone) {
window.ui.loadSchedule();
}
}
handleZoneUpdate(data) {
// Handle zone-specific updates
this.showToast(`Zone ${data.zone} bijgewerkt`, 'info');
// Refresh relevant views
if (window.ui) {
if (window.ui.currentTab === 'zones') {
window.ui.loadZonesOverview();
} else if (window.ui.currentTab === 'content') {
window.ui.loadContent();
}
}
}
handleSystemNotification(data) {
// Handle system-level notifications
const { message, type, duration } = data;
this.showToast(message, type || 'info', duration);
}
updateConnectionStatus(connected) {
const statusDot = document.getElementById('connectionStatus');
const statusText = document.getElementById('connectionText');
if (statusDot) {
statusDot.className = connected ? 'status-dot' : 'status-dot disconnected';
}
if (statusText) {
statusText.textContent = connected ? 'Verbonden' : 'Verbinding verbroken';
}
}
attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
this.showToast('Kan geen verbinding maken met de server', 'error');
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);
}
handleConnectionError() {
this.isConnected = false;
this.updateConnectionStatus(false);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.showToast('Verbinding verbroken. Probeert opnieuw...', 'warning');
} else {
this.showToast('Kan geen verbinding maken met de server', 'error');
}
}
// Public methods
joinZone(zone) {
if (this.isConnected && this.socket) {
this.socket.emit('joinZone', zone);
console.log(`Joined zone: ${zone}`);
}
}
leaveZone(zone) {
if (this.isConnected && this.socket) {
this.socket.emit('leaveZone', zone);
console.log(`Left zone: ${zone}`);
}
}
sendMessage(event, data) {
if (this.isConnected && this.socket) {
this.socket.emit(event, data);
} else {
console.warn('Cannot send message: not connected');
}
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.isConnected = false;
this.updateConnectionStatus(false);
}
}
reconnect() {
this.disconnect();
this.reconnectAttempts = 0;
this.connect();
}
// Utility methods
showToast(message, type = 'info', duration = 5000) {
if (window.ui) {
window.ui.showToast(message, type);
} else {
// Fallback to browser notification
console.log(`Toast [${type}]: ${message}`);
}
}
// Get connection status
getConnectionStatus() {
return {
connected: this.isConnected,
reconnectAttempts: this.reconnectAttempts,
socketId: this.socket?.id || null
};
}
}
// Create global WebSocket instance
window.wsManager = new WebSocketManager();