mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 13:24:46 +01:00
🎿 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:
253
admin/index.html
Normal file
253
admin/index.html
Normal file
@@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SnowWorld - Narrowcasting Admin Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<i class="fas fa-snowflake"></i>
|
||||
<h1>SnowWorld Narrowcasting</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="refreshBtn" class="btn btn-secondary">
|
||||
<i class="fas fa-sync-alt"></i> Verversen
|
||||
</button>
|
||||
<div class="status-indicator">
|
||||
<span id="connectionStatus" class="status-dot"></span>
|
||||
<span id="connectionText">Verbonden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="content">
|
||||
<i class="fas fa-photo-video"></i> Content Beheer
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="schedule">
|
||||
<i class="fas fa-calendar-alt"></i> Planning
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="zones">
|
||||
<i class="fas fa-map-marked-alt"></i> Zones
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="analytics">
|
||||
<i class="fas fa-chart-bar"></i> Analytics
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Content Management Tab -->
|
||||
<div id="content-tab" class="tab-content active">
|
||||
<div class="section-header">
|
||||
<h2>Content Beheer</h2>
|
||||
<button id="addContentBtn" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Content Toevoegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="filter-controls">
|
||||
<select id="zoneFilter" class="form-select">
|
||||
<option value="">Alle Zones</option>
|
||||
</select>
|
||||
<select id="typeFilter" class="form-select">
|
||||
<option value="">Alle Types</option>
|
||||
<option value="image">Afbeeldingen</option>
|
||||
<option value="video">Video's</option>
|
||||
<option value="livestream">Livestreams</option>
|
||||
</select>
|
||||
<button id="applyFilters" class="btn btn-secondary">Toepassen</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Grid -->
|
||||
<div id="contentGrid" class="content-grid">
|
||||
<!-- Content items will be dynamically loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Tab -->
|
||||
<div id="schedule-tab" class="tab-content">
|
||||
<div class="section-header">
|
||||
<h2>Content Planning</h2>
|
||||
<button id="addScheduleBtn" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Planning Toevoegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-container">
|
||||
<div class="zone-selector">
|
||||
<h3>Kies Zone:</h3>
|
||||
<select id="scheduleZoneSelect" class="form-select">
|
||||
<!-- Zones will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="scheduleTimeline" class="schedule-timeline">
|
||||
<!-- Schedule items will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zones Tab -->
|
||||
<div id="zones-tab" class="tab-content">
|
||||
<div class="section-header">
|
||||
<h2>Zone Overzicht</h2>
|
||||
</div>
|
||||
|
||||
<div id="zonesGrid" class="zones-grid">
|
||||
<!-- Zone information will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div id="analytics-tab" class="tab-content">
|
||||
<div class="section-header">
|
||||
<h2>Analytics Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div class="analytics-grid">
|
||||
<div class="analytics-card">
|
||||
<h3>Content Statistieken</h3>
|
||||
<div id="contentStats" class="stats-container">
|
||||
<!-- Content stats will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-card">
|
||||
<h3>Planning Statistieken</h3>
|
||||
<div id="scheduleStats" class="stats-container">
|
||||
<!-- Schedule stats will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-card">
|
||||
<h3>Zone Overzicht</h3>
|
||||
<div id="zoneStats" class="stats-container">
|
||||
<!-- Zone stats will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Content Upload Modal -->
|
||||
<div id="contentModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Content Toevoegen</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="contentUploadForm" class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="contentTitle">Titel:</label>
|
||||
<input type="text" id="contentTitle" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contentType">Type:</label>
|
||||
<select id="contentType" class="form-control" required>
|
||||
<option value="">Kies type...</option>
|
||||
<option value="image">Afbeelding</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="livestream">Livestream</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contentZone">Zone:</label>
|
||||
<select id="contentZone" class="form-control" required>
|
||||
<!-- Zones will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contentDuration">Weergave Duur (seconden):</label>
|
||||
<input type="number" id="contentDuration" class="form-control" min="5" max="300" value="10">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="contentFile">Bestand:</label>
|
||||
<input type="file" id="contentFile" class="form-control" accept="image/*,video/*" required>
|
||||
<div id="fileInfo" class="file-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Annuleren</button>
|
||||
<button type="submit" class="btn btn-primary">Uploaden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div id="scheduleModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Planning Toevoegen</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="scheduleForm" class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="scheduleContent">Content:</label>
|
||||
<select id="scheduleContent" class="form-control" required>
|
||||
<!-- Available content will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scheduleZone">Zone:</label>
|
||||
<select id="scheduleZone" class="form-control" required>
|
||||
<!-- Zones will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scheduleStart">Start Tijd:</label>
|
||||
<input type="datetime-local" id="scheduleStart" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scheduleEnd">Eind Tijd:</label>
|
||||
<input type="datetime-local" id="scheduleEnd" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedulePriority">Prioriteit:</label>
|
||||
<select id="schedulePriority" class="form-control">
|
||||
<option value="1">Laag</option>
|
||||
<option value="2">Normaal</option>
|
||||
<option value="3">Hoog</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeScheduleModal()">Annuleren</button>
|
||||
<button type="submit" class="btn btn-primary">Plannen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toastContainer" class="toast-container">
|
||||
<!-- Toast notifications will appear here -->
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/websocket.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
admin/js/api.js
Normal file
140
admin/js/api.js
Normal 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
367
admin/js/app.js
Normal 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
567
admin/js/ui.js
Normal 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()">×</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
240
admin/js/websocket.js
Normal 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();
|
||||
644
admin/package-lock.json
generated
Normal file
644
admin/package-lock.json
generated
Normal file
@@ -0,0 +1,644 @@
|
||||
{
|
||||
"name": "snowworld-admin-dashboard",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "snowworld-admin-dashboard",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"http-server": "^14.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/corser": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
|
||||
"integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-server": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz",
|
||||
"integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "^2.0.1",
|
||||
"chalk": "^4.1.2",
|
||||
"corser": "^2.0.1",
|
||||
"he": "^1.2.0",
|
||||
"html-encoding-sniffer": "^3.0.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"mime": "^1.6.0",
|
||||
"minimist": "^1.2.6",
|
||||
"opener": "^1.5.1",
|
||||
"portfinder": "^1.0.28",
|
||||
"secure-compare": "3.0.1",
|
||||
"union": "~0.5.0",
|
||||
"url-join": "^4.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"http-server": "bin/http-server"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
|
||||
"dev": true,
|
||||
"license": "(WTFPL OR MIT)",
|
||||
"bin": {
|
||||
"opener": "bin/opener-bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.38",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
|
||||
"integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"debug": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-compare": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz",
|
||||
"integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/union": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz",
|
||||
"integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"qs": "^6.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-join": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
|
||||
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
||||
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
admin/package.json
Normal file
18
admin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "snowworld-admin-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "Admin dashboard for SnowWorld narrowcasting system",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
"start": "http-server -p 8080 -c-1",
|
||||
"build": "echo 'Build complete'",
|
||||
"test": "echo 'No tests specified'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"http-server": "^14.1.1"
|
||||
},
|
||||
"keywords": ["admin", "dashboard", "narrowcasting", "snowworld"],
|
||||
"author": "SnowWorld Development Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
666
admin/styles.css
Normal file
666
admin/styles.css
Normal file
@@ -0,0 +1,666 @@
|
||||
/* SnowWorld Admin Dashboard Styles */
|
||||
:root {
|
||||
--primary-color: #0066cc;
|
||||
--secondary-color: #e6f3ff;
|
||||
--accent-color: #00a8ff;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--dark-color: #2c3e50;
|
||||
--light-color: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
--border-radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
min-height: 100vh;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-color) 100%);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 2rem;
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 300;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--success-color);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--danger-color);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Navigation Tabs */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: var(--light-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 1rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: white;
|
||||
color: var(--primary-color);
|
||||
border-bottom: 3px solid var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
color: var(--dark-color);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0052a3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
composes: form-control;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-controls .form-select {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.content-item {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.content-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
height: 200px;
|
||||
background: var(--light-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.content-preview.video {
|
||||
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.content-preview.video::before {
|
||||
content: '▶';
|
||||
font-size: 3rem;
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.content-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.content-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Schedule Timeline */
|
||||
.schedule-timeline {
|
||||
background: var(--light-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.schedule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.schedule-time {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.schedule-content {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.schedule-content h4 {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.schedule-content p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Analytics */
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analytics-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--light-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Zones Grid */
|
||||
.zones-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.zone-card {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.zone-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.zone-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.zone-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-left: 4px solid var(--success-color);
|
||||
min-width: 300px;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left-color: var(--info-color);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Winter Theme Enhancements */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-color) 100%);
|
||||
}
|
||||
|
||||
.content-item {
|
||||
border-top: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.zone-card {
|
||||
border-top: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
border-top: 3px solid var(--accent-color);
|
||||
}
|
||||
Reference in New Issue
Block a user