This commit is contained in:
Alvin-Zilverstand
2026-02-09 10:59:59 +01:00
parent 83c1f586af
commit 10b9ba4e61
12 changed files with 991 additions and 250 deletions

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SnowWorld - Narrowcasting Admin Dashboard</title> <title>SnowWorld - Narrowcasting Admin Dashboard</title>
<link rel="icon" type="image/svg+xml" href="http://localhost:3000/favicon.svg">
<link rel="stylesheet" href="styles.css"> <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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
@@ -65,6 +66,7 @@
<option value="image">Afbeeldingen</option> <option value="image">Afbeeldingen</option>
<option value="video">Video's</option> <option value="video">Video's</option>
<option value="livestream">Livestreams</option> <option value="livestream">Livestreams</option>
<option value="text">Tekst</option>
</select> </select>
<button id="applyFilters" class="btn btn-secondary">Toepassen</button> <button id="applyFilters" class="btn btn-secondary">Toepassen</button>
</div> </div>
@@ -102,6 +104,9 @@
<div id="zones-tab" class="tab-content"> <div id="zones-tab" class="tab-content">
<div class="section-header"> <div class="section-header">
<h2>Zone Overzicht</h2> <h2>Zone Overzicht</h2>
<button id="addZoneBtn" class="btn btn-primary">
<i class="fas fa-plus"></i> Zone Toevoegen
</button>
</div> </div>
<div id="zonesGrid" class="zones-grid"> <div id="zonesGrid" class="zones-grid">
@@ -161,6 +166,7 @@
<option value="image">Afbeelding</option> <option value="image">Afbeelding</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="livestream">Livestream</option> <option value="livestream">Livestream</option>
<option value="text">Tekst</option>
</select> </select>
</div> </div>
@@ -176,12 +182,19 @@
<input type="number" id="contentDuration" class="form-control" min="5" max="300" value="10"> <input type="number" id="contentDuration" class="form-control" min="5" max="300" value="10">
</div> </div>
<div class="form-group"> <!-- File upload field (shown for image/video) -->
<div id="fileUploadGroup" class="form-group">
<label for="contentFile">Bestand:</label> <label for="contentFile">Bestand:</label>
<input type="file" id="contentFile" class="form-control" accept="image/*,video/*" required> <input type="file" id="contentFile" class="form-control" accept="image/*,video/*">
<div id="fileInfo" class="file-info"></div> <div id="fileInfo" class="file-info"></div>
</div> </div>
<!-- Text content field (shown for text type) -->
<div id="textContentGroup" class="form-group" style="display: none;">
<label for="textContent">Tekst:</label>
<textarea id="textContent" class="form-control" rows="6" placeholder="Voer hier uw tekst in..."></textarea>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Annuleren</button> <button type="button" class="btn btn-secondary" onclick="closeModal()">Annuleren</button>
<button type="submit" class="btn btn-primary">Uploaden</button> <button type="submit" class="btn btn-primary">Uploaden</button>
@@ -239,6 +252,111 @@
</div> </div>
</div> </div>
<!-- Zone Modal -->
<div id="zoneModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Zone Toevoegen</h3>
<button class="close-btn">&times;</button>
</div>
<form id="zoneForm" class="modal-body">
<div class="form-group">
<label for="zoneId">Zone ID (uniek):</label>
<input type="text" id="zoneId" class="form-control" placeholder="bijv. nieuwe-zone" required>
<small>Alleen kleine letters, cijfers en streepjes. Gebruikt in URLs.</small>
</div>
<div class="form-group">
<label for="zoneName">Zone Naam:</label>
<input type="text" id="zoneName" class="form-control" placeholder="bijv. Nieuwe Zone" required>
</div>
<div class="form-group">
<label for="zoneDescription">Beschrijving:</label>
<textarea id="zoneDescription" class="form-control" rows="3" placeholder="Optionele beschrijving van de zone..."></textarea>
</div>
<div class="form-group">
<label for="zoneDisplayOrder">Weergave Volgorde:</label>
<input type="number" id="zoneDisplayOrder" class="form-control" min="0" value="0">
<small>Lager nummer = eerder in de lijst</small>
</div>
<div class="form-group">
<label>Zone Icoon:</label>
<input type="hidden" id="zoneIcon" value="fa-map-marker-alt">
<div class="icon-selector">
<div class="icon-option selected" data-icon="fa-map-marker-alt" title="Standaard">
<i class="fas fa-map-marker-alt"></i>
</div>
<div class="icon-option" data-icon="fa-door-open" title="Ingang">
<i class="fas fa-door-open"></i>
</div>
<div class="icon-option" data-icon="fa-utensils" title="Restaurant">
<i class="fas fa-utensils"></i>
</div>
<div class="icon-option" data-icon="fa-skiing" title="Ski">
<i class="fas fa-skiing"></i>
</div>
<div class="icon-option" data-icon="fa-snowflake" title="Sneeuw">
<i class="fas fa-snowflake"></i>
</div>
<div class="icon-option" data-icon="fa-locker" title="Kluisjes">
<i class="fas fa-locker"></i>
</div>
<div class="icon-option" data-icon="fa-shopping-bag" title="Winkel">
<i class="fas fa-shopping-bag"></i>
</div>
<div class="icon-option" data-icon="fa-globe" title="Wereld">
<i class="fas fa-globe"></i>
</div>
<div class="icon-option" data-icon="fa-home" title="Thuis">
<i class="fas fa-home"></i>
</div>
<div class="icon-option" data-icon="fa-building" title="Gebouw">
<i class="fas fa-building"></i>
</div>
<div class="icon-option" data-icon="fa-mountain" title="Berg">
<i class="fas fa-mountain"></i>
</div>
<div class="icon-option" data-icon="fa-tree" title="Boom">
<i class="fas fa-tree"></i>
</div>
<div class="icon-option" data-icon="fa-sun" title="Zon">
<i class="fas fa-sun"></i>
</div>
<div class="icon-option" data-icon="fa-moon" title="Maan">
<i class="fas fa-moon"></i>
</div>
<div class="icon-option" data-icon="fa-star" title="Ster">
<i class="fas fa-star"></i>
</div>
<div class="icon-option" data-icon="fa-heart" title="Hart">
<i class="fas fa-heart"></i>
</div>
<div class="icon-option" data-icon="fa-info-circle" title="Info">
<i class="fas fa-info-circle"></i>
</div>
<div class="icon-option" data-icon="fa-exclamation-circle" title="Waarschuwing">
<i class="fas fa-exclamation-circle"></i>
</div>
<div class="icon-option" data-icon="fa-check-circle" title="Goed">
<i class="fas fa-check-circle"></i>
</div>
<div class="icon-option" data-icon="fa-users" title="Mensen">
<i class="fas fa-users"></i>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeZoneModal()">Annuleren</button>
<button type="submit" class="btn btn-primary">Toevoegen</button>
</div>
</form>
</div>
</div>
<!-- Toast Notifications --> <!-- Toast Notifications -->
<div id="toastContainer" class="toast-container"> <div id="toastContainer" class="toast-container">
<!-- Toast notifications will appear here --> <!-- Toast notifications will appear here -->

View File

@@ -50,6 +50,13 @@ class APIService {
}); });
} }
async createTextContent(textData) {
return this.request('/content/text', {
method: 'POST',
body: JSON.stringify(textData)
});
}
async deleteContent(contentId) { async deleteContent(contentId) {
return this.request(`/content/${contentId}`, { return this.request(`/content/${contentId}`, {
method: 'DELETE' method: 'DELETE'
@@ -73,6 +80,13 @@ class APIService {
return this.request('/zones'); return this.request('/zones');
} }
async createZone(zoneData) {
return this.request('/zones', {
method: 'POST',
body: JSON.stringify(zoneData)
});
}
// Weather Data // Weather Data
async getWeatherData() { async getWeatherData() {
return this.request('/weather'); return this.request('/weather');

View File

@@ -62,6 +62,29 @@ class UIManager {
document.getElementById('contentFile')?.addEventListener('change', (e) => { document.getElementById('contentFile')?.addEventListener('change', (e) => {
this.previewFile(e.target.files[0]); this.previewFile(e.target.files[0]);
}); });
// Content type change - show/hide appropriate fields
document.getElementById('contentType')?.addEventListener('change', (e) => {
this.handleContentTypeChange(e.target.value);
});
// Zone management
document.getElementById('addZoneBtn')?.addEventListener('click', () => {
this.openZoneModal();
});
document.getElementById('zoneForm')?.addEventListener('submit', (e) => {
e.preventDefault();
this.createZone();
});
// Icon selector
document.querySelectorAll('.icon-option').forEach(option => {
option.addEventListener('click', (e) => {
const icon = e.currentTarget.dataset.icon;
this.selectIcon(icon);
});
});
} }
// Tab Management // Tab Management
@@ -148,13 +171,15 @@ class UIManager {
const typeIcon = { const typeIcon = {
'image': 'fa-image', 'image': 'fa-image',
'video': 'fa-video', 'video': 'fa-video',
'livestream': 'fa-broadcast-tower' 'livestream': 'fa-broadcast-tower',
'text': 'fa-font'
}[item.type] || 'fa-file'; }[item.type] || 'fa-file';
const typeLabel = { const typeLabel = {
'image': 'Afbeelding', 'image': 'Afbeelding',
'video': 'Video', 'video': 'Video',
'livestream': 'Livestream' 'livestream': 'Livestream',
'text': 'Tekst'
}[item.type] || 'Bestand'; }[item.type] || 'Bestand';
return ` return `
@@ -162,6 +187,8 @@ class UIManager {
<div class="content-preview ${item.type}"> <div class="content-preview ${item.type}">
${item.type === 'image' ? ${item.type === 'image' ?
`<img src="${item.url}" alt="${item.title}" onerror="this.src='https://via.placeholder.com/300x200?text=Afbeelding'">` : `<img src="${item.url}" alt="${item.title}" onerror="this.src='https://via.placeholder.com/300x200?text=Afbeelding'">` :
item.type === 'text' ?
`<div class="text-preview"><i class="fas ${typeIcon} fa-3x"></i><p>${item.textContent ? item.textContent.substring(0, 50) + '...' : 'Tekst content'}</p></div>` :
`<i class="fas ${typeIcon} fa-3x"></i>` `<i class="fas ${typeIcon} fa-3x"></i>`
} }
</div> </div>
@@ -184,10 +211,19 @@ class UIManager {
} }
// Modal Management // Modal Management
openContentModal() { async openContentModal() {
const modal = document.getElementById('contentModal'); const modal = document.getElementById('contentModal');
if (!modal) {
console.error('Content modal not found');
return;
}
modal.classList.add('active'); modal.classList.add('active');
this.loadZonesSelect('contentZone');
// Reset form fields visibility before loading zones
this.handleContentTypeChange('');
// Load zones into dropdown
await this.loadZonesSelect('contentZone');
} }
openScheduleModal() { openScheduleModal() {
@@ -206,7 +242,17 @@ class UIManager {
// Reset forms // Reset forms
document.getElementById('contentUploadForm')?.reset(); document.getElementById('contentUploadForm')?.reset();
document.getElementById('scheduleForm')?.reset(); document.getElementById('scheduleForm')?.reset();
document.getElementById('zoneForm')?.reset();
document.getElementById('fileInfo').innerHTML = ''; document.getElementById('fileInfo').innerHTML = '';
// Reset text content field
const textContent = document.getElementById('textContent');
if (textContent) {
textContent.value = '';
}
// Reset form fields visibility
this.handleContentTypeChange('');
} }
// Content Upload // Content Upload
@@ -232,39 +278,85 @@ class UIManager {
} }
} }
async uploadContent() { handleContentTypeChange(type) {
const form = document.getElementById('contentUploadForm'); const fileUploadGroup = document.getElementById('fileUploadGroup');
const formData = new FormData(); const textContentGroup = document.getElementById('textContentGroup');
const contentFile = document.getElementById('contentFile');
const textContent = document.getElementById('textContent');
const fileInput = document.getElementById('contentFile'); if (type === 'text') {
if (fileUploadGroup) fileUploadGroup.style.display = 'none';
if (textContentGroup) textContentGroup.style.display = 'block';
if (contentFile) contentFile.removeAttribute('required');
if (textContent) textContent.setAttribute('required', 'required');
} else {
if (fileUploadGroup) fileUploadGroup.style.display = 'block';
if (textContentGroup) textContentGroup.style.display = 'none';
if (contentFile) contentFile.setAttribute('required', 'required');
if (textContent) textContent.removeAttribute('required');
}
}
async uploadContent() {
const title = document.getElementById('contentTitle').value; const title = document.getElementById('contentTitle').value;
const type = document.getElementById('contentType').value; const type = document.getElementById('contentType').value;
const zone = document.getElementById('contentZone').value; const zone = document.getElementById('contentZone').value;
const duration = document.getElementById('contentDuration').value; const duration = document.getElementById('contentDuration').value;
if (!fileInput.files[0]) { if (!type) {
this.showToast('Selecteer een bestand', 'error'); this.showToast('Selecteer een type', 'error');
return; return;
} }
try {
this.showLoading('Bezig met opslaan...');
if (type === 'text') {
// Handle text content
const textContent = document.getElementById('textContent').value;
if (!textContent.trim()) {
this.showToast('Voer tekst in', 'error');
this.hideLoading();
return;
}
const textData = {
title: title,
textContent: textContent,
zone: zone,
duration: parseInt(duration)
};
const result = await api.createTextContent(textData);
} else {
// Handle file upload
const fileInput = document.getElementById('contentFile');
if (!fileInput.files[0]) {
this.showToast('Selecteer een bestand', 'error');
this.hideLoading();
return;
}
const formData = new FormData();
formData.append('content', fileInput.files[0]); formData.append('content', fileInput.files[0]);
formData.append('title', title); formData.append('title', title);
formData.append('type', type); formData.append('type', type);
formData.append('zone', zone); formData.append('zone', zone);
formData.append('duration', duration); formData.append('duration', duration);
try {
this.showLoading('Bezig met uploaden...');
const result = await api.uploadContent(formData); const result = await api.uploadContent(formData);
}
this.closeModals(); this.closeModals();
this.clearContentCache(); this.clearContentCache();
await this.loadContent(); await this.loadContent();
this.showToast('Content succesvol geüpload!', 'success'); this.showToast('Content succesvol opgeslagen!', 'success');
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
this.showToast('Upload mislukt: ' + error.message, 'error'); this.showToast('Opslaan mislukt: ' + error.message, 'error');
} finally { } finally {
this.hideLoading(); this.hideLoading();
} }
@@ -368,6 +460,50 @@ class UIManager {
document.getElementById('scheduleEnd').value = endTime.toISOString().slice(0, 16); document.getElementById('scheduleEnd').value = endTime.toISOString().slice(0, 16);
} }
async createZone() {
const zoneId = document.getElementById('zoneId').value.trim();
const zoneName = document.getElementById('zoneName').value.trim();
const zoneDescription = document.getElementById('zoneDescription').value.trim();
const zoneDisplayOrder = document.getElementById('zoneDisplayOrder').value;
const zoneIcon = document.getElementById('zoneIcon').value;
if (!zoneId || !zoneName) {
this.showToast('Zone ID en naam zijn verplicht', 'error');
return;
}
// Validate zone ID format (lowercase letters, numbers, hyphens only)
const validIdPattern = /^[a-z0-9-]+$/;
if (!validIdPattern.test(zoneId)) {
this.showToast('Zone ID mag alleen kleine letters, cijfers en streepjes bevatten', 'error');
return;
}
const zoneData = {
id: zoneId,
name: zoneName,
description: zoneDescription,
icon: zoneIcon,
displayOrder: parseInt(zoneDisplayOrder) || 0
};
try {
this.showLoading('Bezig met toevoegen...');
await api.createZone(zoneData);
this.closeModals();
this.zonesCache = null; // Clear cache
await this.loadZonesOverview();
this.showToast('Zone succesvol toegevoegd!', 'success');
} catch (error) {
console.error('Zone creation error:', error);
this.showToast('Zone toevoegen mislukt: ' + error.message, 'error');
} finally {
this.hideLoading();
}
}
// Zones Management // Zones Management
async loadZones() { async loadZones() {
if (this.zonesCache) return this.zonesCache; if (this.zonesCache) return this.zonesCache;
@@ -382,13 +518,32 @@ class UIManager {
} }
async loadZonesSelect(selectId) { async loadZonesSelect(selectId) {
try {
const zones = await this.loadZones(); const zones = await this.loadZones();
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
if (!select) return; if (!select) {
console.error(`Select element with id '${selectId}' not found`);
return;
}
if (!zones || zones.length === 0) {
console.error('No zones loaded');
select.innerHTML = '<option value="">Geen zones beschikbaar</option>';
return;
}
select.innerHTML = zones.map(zone => select.innerHTML = zones.map(zone =>
`<option value="${zone.id}">${zone.name}</option>` `<option value="${zone.id}">${zone.name}</option>`
).join(''); ).join('');
console.log(`Loaded ${zones.length} zones into ${selectId}`);
} catch (error) {
console.error('Error in loadZonesSelect:', error);
const select = document.getElementById(selectId);
if (select) {
select.innerHTML = '<option value="">Fout bij laden zones</option>';
}
}
} }
async loadContentSelect() { async loadContentSelect() {
@@ -410,19 +565,10 @@ class UIManager {
const grid = document.getElementById('zonesGrid'); const grid = document.getElementById('zonesGrid');
if (!grid) return; 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 => ` grid.innerHTML = zones.map(zone => `
<div class="zone-card"> <div class="zone-card">
<div class="zone-icon"> <div class="zone-icon">
<i class="fas ${zoneIcons[zone.id] || 'fa-map-marker-alt'} fa-3x"></i> <i class="fas ${zone.icon || 'fa-map-marker-alt'} fa-3x"></i>
</div> </div>
<h3 class="zone-name">${zone.name}</h3> <h3 class="zone-name">${zone.name}</h3>
<p class="zone-description">${zone.description}</p> <p class="zone-description">${zone.description}</p>
@@ -430,6 +576,32 @@ class UIManager {
`).join(''); `).join('');
} }
selectIcon(iconName) {
// Update hidden input
document.getElementById('zoneIcon').value = iconName;
// Update visual selection
document.querySelectorAll('.icon-option').forEach(option => {
option.classList.remove('selected');
if (option.dataset.icon === iconName) {
option.classList.add('selected');
}
});
}
openZoneModal() {
const modal = document.getElementById('zoneModal');
if (!modal) {
console.error('Zone modal not found');
return;
}
modal.classList.add('active');
// Reset icon selection to default
this.selectIcon('fa-map-marker-alt');
}
}
// Analytics // Analytics
async loadAnalytics() { async loadAnalytics() {
try { try {
@@ -565,3 +737,22 @@ class UIManager {
// Create global UI instance // Create global UI instance
window.ui = new UIManager(); window.ui = new UIManager();
// Global helper functions for onclick handlers
window.closeModal = function() {
if (window.ui) {
window.ui.closeModals();
}
};
window.closeScheduleModal = function() {
if (window.ui) {
window.ui.closeModals();
}
};
window.closeZoneModal = function() {
if (window.ui) {
window.ui.closeModals();
}
};

View File

@@ -98,9 +98,8 @@ body {
} }
@keyframes pulse { @keyframes pulse {
0% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
100% { opacity: 1; }
} }
/* Navigation Tabs */ /* Navigation Tabs */
@@ -108,33 +107,32 @@ body {
display: flex; display: flex;
background: var(--light-color); background: var(--light-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
overflow-x: auto; padding: 0 2rem;
} }
.nav-tab { .nav-tab {
padding: 1rem 1.5rem;
background: none; background: none;
border: none; border: none;
padding: 1rem 1.5rem;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
color: var(--text-secondary); color: var(--text-secondary);
border-bottom: 3px solid transparent;
transition: var(--transition); transition: var(--transition);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
white-space: nowrap;
} }
.nav-tab:hover { .nav-tab:hover {
background: var(--secondary-color);
color: var(--primary-color); color: var(--primary-color);
background: var(--secondary-color);
} }
.nav-tab.active { .nav-tab.active {
background: white;
color: var(--primary-color); color: var(--primary-color);
border-bottom: 3px solid var(--primary-color); border-bottom-color: var(--primary-color);
font-weight: 500; background: white;
} }
/* Main Content */ /* Main Content */
@@ -150,34 +148,33 @@ body {
display: block; display: block;
} }
/* Section Header */
.section-header { .section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 2px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.section-header h2 { .section-header h2 {
color: var(--dark-color); color: var(--dark-color);
font-size: 1.8rem; font-weight: 400;
font-weight: 500;
} }
/* Buttons */ /* Buttons */
.btn { .btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
transition: var(--transition); transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none; text-decoration: none;
font-weight: 500;
} }
.btn-primary { .btn-primary {
@@ -191,14 +188,13 @@ body {
} }
.btn-secondary { .btn-secondary {
background: var(--secondary-color); background: var(--light-color);
color: var(--primary-color); color: var(--text-primary);
border: 1px solid var(--primary-color); border: 1px solid var(--border-color);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: var(--primary-color); background: var(--border-color);
color: white;
} }
.btn-danger { .btn-danger {
@@ -212,39 +208,7 @@ body {
.btn-small { .btn-small {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.9rem; font-size: 0.875rem;
}
/* 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 */
@@ -252,11 +216,29 @@ body {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
flex-wrap: wrap; align-items: center;
} }
.filter-controls .form-select { .form-select,
min-width: 200px; .form-control {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 1rem;
min-width: 150px;
}
.form-control:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
textarea.form-control {
min-height: 120px;
resize: vertical;
font-family: inherit;
} }
/* Content Grid */ /* Content Grid */
@@ -271,8 +253,8 @@ body {
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
transition: var(--transition);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
transition: var(--transition);
} }
.content-item:hover { .content-item:hover {
@@ -286,7 +268,6 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -296,24 +277,63 @@ body {
object-fit: cover; object-fit: cover;
} }
.content-preview.video { .content-preview i {
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%); color: var(--text-secondary);
opacity: 0.6;
} }
.content-preview.video::before { .content-preview.video {
content: '▶'; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-size: 3rem; }
.content-preview.video i {
color: white; color: white;
opacity: 0.8; }
.content-preview.livestream {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.content-preview.livestream i {
color: white;
}
.content-preview.text {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.content-preview.text i {
color: white;
}
.text-preview {
text-align: center;
color: white;
padding: 1rem;
}
.text-preview i {
margin-bottom: 0.5rem;
}
.text-preview p {
font-size: 0.875rem;
opacity: 0.9;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
.content-info { .content-info {
padding: 1.5rem; padding: 1rem;
} }
.content-title { .content-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 500;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--dark-color); color: var(--dark-color);
} }
@@ -321,10 +341,16 @@ body {
.content-meta { .content-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
margin-bottom: 1rem; font-size: 0.875rem;
font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 1rem;
}
.content-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
} }
.content-actions { .content-actions {
@@ -332,6 +358,18 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state i {
margin-bottom: 1rem;
color: var(--border-color);
}
/* Modal Styles */ /* Modal Styles */
.modal { .modal {
display: none; display: none;
@@ -342,19 +380,18 @@ body {
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 1000;
backdrop-filter: blur(5px); align-items: center;
justify-content: center;
} }
.modal.active { .modal.active {
display: flex; display: flex;
align-items: center;
justify-content: center;
} }
.modal-content { .modal-content {
background: white; background: white;
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
max-height: 90vh; max-height: 90vh;
@@ -391,100 +428,79 @@ body {
padding: 1.5rem; padding: 1.5rem;
} }
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--dark-color);
}
.file-info {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--light-color);
border-radius: var(--border-radius);
font-size: 0.875rem;
}
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: flex-end; justify-content: flex-end;
margin-top: 2rem; margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
} }
/* Schedule Timeline */ /* Schedule Styles */
.schedule-timeline { .schedule-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
}
.zone-selector {
background: var(--light-color); background: var(--light-color);
padding: 1.5rem;
border-radius: var(--border-radius);
height: fit-content;
}
.schedule-timeline {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; min-height: 400px;
} }
.schedule-item { .schedule-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem;
padding: 1rem; padding: 1rem;
background: white; border-left: 4px solid var(--primary-color);
background: var(--light-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
margin-bottom: 1rem; margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid var(--primary-color);
} }
.schedule-time { .schedule-time {
font-weight: 600; font-weight: 500;
color: var(--primary-color); color: var(--primary-color);
min-width: 150px; min-width: 120px;
}
.schedule-content {
flex: 1;
margin-left: 1rem;
} }
.schedule-content h4 { .schedule-content h4 {
margin-bottom: 0.25rem; margin: 0 0 0.25rem 0;
color: var(--dark-color);
} }
.schedule-content p { .schedule-content p {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary); 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 */
@@ -497,10 +513,10 @@ body {
.zone-card { .zone-card {
background: white; background: white;
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
border: 1px solid var(--border-color); padding: 2rem;
text-align: center; text-align: center;
border: 1px solid var(--border-color);
transition: var(--transition); transition: var(--transition);
} }
@@ -510,57 +526,114 @@ body {
} }
.zone-icon { .zone-icon {
font-size: 3rem;
color: var(--primary-color);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.zone-icon i {
color: var(--primary-color);
}
.zone-name { .zone-name {
font-size: 1.2rem; font-size: 1.25rem;
font-weight: 600; font-weight: 500;
color: var(--dark-color);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--dark-color);
} }
.zone-description { .zone-description {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.875rem;
}
/* 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);
box-shadow: var(--shadow);
padding: 1.5rem;
border: 1px solid var(--border-color);
}
.analytics-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
font-weight: 500;
}
.stats-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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 {
color: var(--text-secondary);
}
.stat-value {
font-weight: 500;
color: var(--dark-color);
} }
/* Toast Notifications */ /* Toast Notifications */
.toast-container { .toast-container {
position: fixed; position: fixed;
top: 20px; top: 2rem;
right: 20px; right: 2rem;
z-index: 1100; z-index: 2000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 0.5rem;
} }
.toast { .toast {
background: white; display: flex;
border-radius: var(--border-radius); align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border-radius: var(--border-radius);
border-left: 4px solid var(--success-color); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
min-width: 300px; animation: slideIn 0.3s ease;
animation: slideInRight 0.3s ease; max-width: 400px;
}
.toast.success {
background: var(--success-color);
color: white;
} }
.toast.error { .toast.error {
border-left-color: var(--danger-color); background: var(--danger-color);
} color: white;
.toast.warning {
border-left-color: var(--warning-color);
} }
.toast.info { .toast.info {
border-left-color: var(--info-color); background: var(--primary-color);
color: white;
} }
@keyframes slideInRight { .toast.warning {
background: var(--warning-color);
color: var(--dark-color);
}
@keyframes slideIn {
from { from {
transform: translateX(100%); transform: translateX(100%);
opacity: 0; opacity: 0;
@@ -571,6 +644,46 @@ body {
} }
} }
.toast-close {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.25rem;
opacity: 0.7;
transition: var(--transition);
}
.toast-close:hover {
opacity: 1;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
}
.loading-content {
background: white;
padding: 2rem;
border-radius: var(--border-radius);
text-align: center;
}
.loading-content p {
margin-top: 1rem;
color: var(--text-secondary);
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
@@ -580,30 +693,24 @@ body {
.header-content { .header-content {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
text-align: center;
} }
.nav-tabs { .nav-tabs {
flex-wrap: wrap; flex-wrap: wrap;
} padding: 0 1rem;
.nav-tab {
flex: 1;
min-width: 150px;
} }
.main-content { .main-content {
padding: 1rem; padding: 1rem;
} }
.section-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.filter-controls { .filter-controls {
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.schedule-container {
grid-template-columns: 1fr;
} }
.content-grid { .content-grid {
@@ -615,8 +722,8 @@ body {
} }
.modal-content { .modal-content {
width: 95%;
margin: 1rem; margin: 1rem;
max-width: none;
} }
} }
@@ -664,3 +771,45 @@ body {
.analytics-card { .analytics-card {
border-top: 3px solid var(--accent-color); border-top: 3px solid var(--accent-color);
} }
/* Icon Selector Styles */
.icon-selector {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
}
.icon-option {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
transition: var(--transition);
background: white;
}
.icon-option:hover {
border-color: var(--primary-color);
background: var(--secondary-color);
transform: scale(1.05);
}
.icon-option.selected {
border-color: var(--primary-color);
background: var(--primary-color);
color: white;
}
.icon-option i {
font-size: 1.5rem;
}
@media (max-width: 768px) {
.icon-selector {
grid-template-columns: repeat(4, 1fr);
}
}

View File

@@ -61,6 +61,7 @@ class DatabaseManager {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
icon TEXT DEFAULT 'fa-map-marker-alt',
displayOrder INTEGER DEFAULT 0, displayOrder INTEGER DEFAULT 0,
isActive INTEGER DEFAULT 1 isActive INTEGER DEFAULT 1
) )
@@ -82,23 +83,23 @@ class DatabaseManager {
this.db.run(zonesTable); this.db.run(zonesTable);
this.db.run(logsTable); this.db.run(logsTable);
// Insert default zones // Insert default zones with icons
const defaultZones = [ const defaultZones = [
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', displayOrder: 1 }, { id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', icon: 'fa-door-open', displayOrder: 1 },
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', displayOrder: 2 }, { id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', icon: 'fa-utensils', displayOrder: 2 },
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', displayOrder: 3 }, { id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', icon: 'fa-skiing', displayOrder: 3 },
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', displayOrder: 4 }, { id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', icon: 'fa-locker', displayOrder: 4 },
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', displayOrder: 5 }, { id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', icon: 'fa-shopping-bag', displayOrder: 5 },
{ id: 'all', name: 'Alle zones', description: 'Toon op alle schermen', displayOrder: 0 } { id: 'all', name: 'Alle zones', description: 'Toon op alle schermen', icon: 'fa-globe', displayOrder: 0 }
]; ];
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT OR IGNORE INTO zones (id, name, description, displayOrder) INSERT OR IGNORE INTO zones (id, name, description, icon, displayOrder)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
defaultZones.forEach(zone => { defaultZones.forEach(zone => {
stmt.run(zone.id, zone.name, zone.description, zone.displayOrder); stmt.run(zone.id, zone.name, zone.description, zone.icon, zone.displayOrder);
}); });
stmt.finalize(); stmt.finalize();
@@ -111,22 +112,57 @@ class DatabaseManager {
async addContent(contentData) { async addContent(contentData) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO content (id, type, title, filename, originalName, mimeType, size, path, url, zone, duration, createdAt) INSERT INTO content (id, type, title, filename, originalName, mimeType, size, path, url, zone, duration, textContent, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.run( stmt.run(
contentData.id, contentData.id,
contentData.type, contentData.type,
contentData.title, contentData.title,
contentData.filename, contentData.filename || '',
contentData.originalName, contentData.originalName || '',
contentData.mimeType, contentData.mimeType || '',
contentData.size, contentData.size || 0,
contentData.path, contentData.path || '',
contentData.url, contentData.url || '',
contentData.zone, contentData.zone,
contentData.duration, contentData.duration,
contentData.textContent || null,
contentData.createdAt,
function(err) {
if (err) {
reject(err);
} else {
resolve(contentData);
}
}
);
stmt.finalize();
});
}
async addTextContent(contentData) {
return new Promise((resolve, reject) => {
const stmt = this.db.prepare(`
INSERT INTO content (id, type, title, filename, originalName, mimeType, size, path, url, zone, duration, textContent, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
contentData.id,
'text',
contentData.title,
'',
'',
'text/plain',
0,
'',
'',
contentData.zone,
contentData.duration,
contentData.textContent,
contentData.createdAt, contentData.createdAt,
function(err) { function(err) {
if (err) { if (err) {
@@ -257,6 +293,33 @@ class DatabaseManager {
}); });
} }
async addZone(zoneData) {
return new Promise((resolve, reject) => {
const stmt = this.db.prepare(`
INSERT INTO zones (id, name, description, icon, displayOrder, isActive)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(
zoneData.id,
zoneData.name,
zoneData.description || '',
zoneData.icon || 'fa-map-marker-alt',
zoneData.displayOrder || 0,
1,
function(err) {
if (err) {
reject(err);
} else {
resolve(zoneData);
}
}
);
stmt.finalize();
});
}
// Logging // Logging
async addLog(type, message, data = null) { async addLog(type, message, data = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -95,6 +95,39 @@ app.post('/api/content/upload', upload.single('content'), async (req, res) => {
} }
}); });
// Text Content Management
app.post('/api/content/text', async (req, res) => {
try {
const { title, textContent, zone, duration } = req.body;
if (!title || !textContent) {
return res.status(400).json({ error: 'Title and text content are required' });
}
const contentData = {
id: uuidv4(),
title: title,
textContent: textContent,
zone: zone || 'all',
duration: parseInt(duration) || 15,
createdAt: new Date().toISOString()
};
const content = await contentManager.addTextContent(contentData);
// Emit real-time update
io.emit('contentUpdated', {
type: 'content_added',
content: content
});
res.json({ success: true, content });
} catch (error) {
console.error('Text content creation error:', error);
res.status(500).json({ error: 'Failed to create text content' });
}
});
app.get('/api/content', async (req, res) => { app.get('/api/content', async (req, res) => {
try { try {
const { zone, type } = req.query; const { zone, type } = req.query;
@@ -172,16 +205,44 @@ app.get('/api/schedule/:zone', async (req, res) => {
} }
}); });
app.get('/api/zones', (req, res) => { app.get('/api/zones', async (req, res) => {
const zones = [ try {
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie' }, const zones = await dbManager.getZones();
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid' },
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan' },
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes' },
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel' },
{ id: 'all', name: 'Alle zones', description: 'Toon op alle schermen' }
];
res.json(zones); res.json(zones);
} catch (error) {
console.error('Get zones error:', error);
res.status(500).json({ error: 'Failed to retrieve zones' });
}
});
app.post('/api/zones', async (req, res) => {
try {
const { id, name, description, icon, displayOrder } = req.body;
if (!id || !name) {
return res.status(400).json({ error: 'Zone ID and name are required' });
}
const zoneData = {
id: id.toLowerCase().replace(/\s+/g, '-'),
name: name,
description: description || '',
icon: icon || 'fa-map-marker-alt',
displayOrder: parseInt(displayOrder) || 0
};
const zone = await dbManager.addZone(zoneData);
io.emit('zonesUpdated', {
type: 'zone_added',
zone: zone
});
res.json({ success: true, zone });
} catch (error) {
console.error('Create zone error:', error);
res.status(500).json({ error: 'Failed to create zone' });
}
}); });
// Weather widget data // Weather widget data

View File

@@ -14,6 +14,17 @@ class ContentManager {
} }
} }
async addTextContent(contentData) {
try {
const content = await this.db.addTextContent(contentData);
await this.db.addLog('content', 'Text content added', { contentId: content.id, type: 'text' });
return content;
} catch (error) {
console.error('Error adding text content:', error);
throw error;
}
}
async getContent(zone = null, type = null) { async getContent(zone = null, type = null) {
try { try {
return await this.db.getContent(zone, type); return await this.db.getContent(zone, type);
@@ -92,7 +103,8 @@ class ContentManager {
const allowedTypes = { const allowedTypes = {
'image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], 'image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'video': ['video/mp4', 'video/webm', 'video/ogg'], 'video': ['video/mp4', 'video/webm', 'video/ogg'],
'livestream': ['application/x-mpegURL', 'application/vnd.apple.mpegurl'] 'livestream': ['application/x-mpegURL', 'application/vnd.apple.mpegurl'],
'text': ['text/plain']
}; };
for (const [type, mimeTypes] of Object.entries(allowedTypes)) { for (const [type, mimeTypes] of Object.entries(allowedTypes)) {
@@ -109,7 +121,8 @@ class ContentManager {
const defaultDurations = { const defaultDurations = {
'image': 10, 'image': 10,
'video': 30, 'video': 30,
'livestream': 3600 // 1 hour for livestreams 'livestream': 3600, // 1 hour for livestreams
'text': 15
}; };
// For videos, estimate duration based on file size (rough approximation) // For videos, estimate duration based on file size (rough approximation)

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SnowWorld - Narrowcasting Display</title> <title>SnowWorld - Narrowcasting Display</title>
<link rel="icon" type="image/svg+xml" href="http://localhost:3000/favicon.svg">
<link rel="stylesheet" href="styles.css"> <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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>

View File

@@ -7,15 +7,29 @@ class DisplayManager {
this.transitionDuration = 1000; // 1 second this.transitionDuration = 1000; // 1 second
this.isPlaying = false; this.isPlaying = false;
this.zone = this.getZoneFromURL() || 'reception'; this.zone = this.getZoneFromURL() || 'reception';
this.zoneData = null;
this.init(); this.init();
} }
init() { async init() {
this.setupEventListeners(); this.setupEventListeners();
await this.loadZoneData();
this.updateZoneDisplay(); this.updateZoneDisplay();
this.hideLoadingScreen(); this.hideLoadingScreen();
} }
async loadZoneData() {
try {
const response = await fetch(`http://localhost:3000/api/zones`);
if (!response.ok) throw new Error('Failed to fetch zones');
const zones = await response.json();
this.zoneData = zones.find(z => z.id === this.zone) || null;
} catch (error) {
console.error('Error loading zone data:', error);
this.zoneData = null;
}
}
setupEventListeners() { setupEventListeners() {
// Handle visibility change (tab switching) // Handle visibility change (tab switching)
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
@@ -44,12 +58,25 @@ class DisplayManager {
updateZoneDisplay() { updateZoneDisplay() {
const zoneElement = document.getElementById('currentZone'); const zoneElement = document.getElementById('currentZone');
const zoneIconElement = document.querySelector('#zoneIndicator .zone-info i');
if (zoneElement) { if (zoneElement) {
zoneElement.textContent = this.getZoneDisplayName(this.zone); zoneElement.textContent = this.getZoneDisplayName(this.zone);
} }
// Update icon if we have zone data
if (zoneIconElement && this.zoneData && this.zoneData.icon) {
zoneIconElement.className = `fas ${this.zoneData.icon}`;
}
} }
getZoneDisplayName(zoneId) { getZoneDisplayName(zoneId) {
// Use zone data from server if available
if (this.zoneData && this.zoneData.name) {
return this.zoneData.name;
}
// Fallback to hardcoded names
const zoneNames = { const zoneNames = {
'reception': 'Receptie', 'reception': 'Receptie',
'restaurant': 'Restaurant', 'restaurant': 'Restaurant',
@@ -167,6 +194,15 @@ class DisplayManager {
`; `;
break; break;
case 'text':
element.innerHTML = `
<div class="text-content">
<h2 class="text-content-title">${contentItem.title}</h2>
<div class="text-content-body">${contentItem.textContent}</div>
</div>
`;
break;
default: default:
element.innerHTML = ` element.innerHTML = `
<div class="content-placeholder"> <div class="content-placeholder">
@@ -335,10 +371,11 @@ class DisplayManager {
this.loadContent(newContent); this.loadContent(newContent);
} }
setZone(zone) { async setZone(zone) {
if (this.zone !== zone) { if (this.zone !== zone) {
console.log(`Zone changed from ${this.zone} to ${zone}`); console.log(`Zone changed from ${this.zone} to ${zone}`);
this.zone = zone; this.zone = zone;
await this.loadZoneData();
this.updateZoneDisplay(); this.updateZoneDisplay();
// Request new content for this zone // Request new content for this zone

View File

@@ -166,6 +166,37 @@ body {
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
/* Text Content Styles */
.text-content {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 249, 250, 0.95) 100%);
color: var(--text-primary);
padding: 4rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
max-width: 80%;
max-height: 70%;
overflow: auto;
text-align: center;
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
}
.text-content-title {
font-size: 3rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 2rem;
text-shadow: 2px 2px 4px rgba(0, 102, 204, 0.1);
}
.text-content-body {
font-size: 1.8rem;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
}
.content-placeholder i { .content-placeholder i {
font-size: 6rem; font-size: 6rem;
margin-bottom: 1rem; margin-bottom: 1rem;

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# SnowWorld Narrowcasting System - Docker Compose (GitHub Container Registry)
# Deze versie pulled de image van ghcr.io in plaats van lokaal te builden
version: '3.8'
services:
snowworld-narrowcasting:
image: ghcr.io/alvin-zilverstand/snowworld-narrowcasting:latest
container_name: snowworld-narrowcasting
restart: unless-stopped
ports:
- "0.0.0.0:3000:3000"
volumes:
# Mount de lokale database, logs en uploads voor persistentie
- ./database:/app/database
- ./logs:/app/logs
- ./public/uploads:/app/public/uploads
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
- TZ=Europe/Amsterdam
networks:
- snowworld-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/zones', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optionele nginx reverse proxy voor productie
nginx:
image: nginx:alpine
container_name: snowworld-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./deployment/configs/nginx.conf:/etc/nginx/nginx.conf:ro
# SSL certificaten (indien beschikbaar)
- ./ssl:/etc/nginx/ssl:ro
depends_on:
snowworld-narrowcasting:
condition: service_healthy
restart: unless-stopped
networks:
- snowworld-network
profiles:
- production
networks:
snowworld-network:
driver: bridge
name: snowworld-network
volumes:
database-data:
name: snowworld-database
uploads-data:
name: snowworld-uploads
logs-data:
name: snowworld-logs

View File

@@ -29,7 +29,7 @@
"docker:build": "docker build -f deployment/docker/Dockerfile -t ghcr.io/alvin-zilverstand/narrow-casting-system .", "docker:build": "docker build -f deployment/docker/Dockerfile -t ghcr.io/alvin-zilverstand/narrow-casting-system .",
"docker:run": "docker run -d -p 3000:3000 --name snowworld ghcr.io/alvin-zilverstand/narrow-casting-system", "docker:run": "docker run -d -p 3000:3000 --name snowworld ghcr.io/alvin-zilverstand/narrow-casting-system",
"docker:tag-fix": "echo 'Note: Docker images must use lowercase repository names'", "docker:tag-fix": "echo 'Note: Docker images must use lowercase repository names'",
"docker:ghcr-login": "echo 'Using GitHub Container Registry with automatic authentication'" "docker:ghcr-login": "echo 'Using GitHub Container Registry with automatic authentication'",
"docker:compose": "cd deployment/docker && docker compose up -d", "docker:compose": "cd deployment/docker && docker compose up -d",
"docker:compose-down": "cd deployment/docker && docker compose down", "docker:compose-down": "cd deployment/docker && docker compose down",
"docker:compose-logs": "cd deployment/docker && docker compose logs -f" "docker:compose-logs": "cd deployment/docker && docker compose logs -f"