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 name="viewport" content="width=device-width, initial-scale=1.0">
<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 href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
@@ -65,6 +66,7 @@
<option value="image">Afbeeldingen</option>
<option value="video">Video's</option>
<option value="livestream">Livestreams</option>
<option value="text">Tekst</option>
</select>
<button id="applyFilters" class="btn btn-secondary">Toepassen</button>
</div>
@@ -102,6 +104,9 @@
<div id="zones-tab" class="tab-content">
<div class="section-header">
<h2>Zone Overzicht</h2>
<button id="addZoneBtn" class="btn btn-primary">
<i class="fas fa-plus"></i> Zone Toevoegen
</button>
</div>
<div id="zonesGrid" class="zones-grid">
@@ -161,6 +166,7 @@
<option value="image">Afbeelding</option>
<option value="video">Video</option>
<option value="livestream">Livestream</option>
<option value="text">Tekst</option>
</select>
</div>
@@ -176,12 +182,19 @@
<input type="number" id="contentDuration" class="form-control" min="5" max="300" value="10">
</div>
<div class="form-group">
<!-- File upload field (shown for image/video) -->
<div id="fileUploadGroup" class="form-group">
<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>
<!-- 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">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Annuleren</button>
<button type="submit" class="btn btn-primary">Uploaden</button>
@@ -239,6 +252,111 @@
</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 -->
<div id="toastContainer" class="toast-container">
<!-- 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) {
return this.request(`/content/${contentId}`, {
method: 'DELETE'
@@ -73,6 +80,13 @@ class APIService {
return this.request('/zones');
}
async createZone(zoneData) {
return this.request('/zones', {
method: 'POST',
body: JSON.stringify(zoneData)
});
}
// Weather Data
async getWeatherData() {
return this.request('/weather');

View File

@@ -62,6 +62,29 @@ class UIManager {
document.getElementById('contentFile')?.addEventListener('change', (e) => {
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
@@ -148,13 +171,15 @@ class UIManager {
const typeIcon = {
'image': 'fa-image',
'video': 'fa-video',
'livestream': 'fa-broadcast-tower'
'livestream': 'fa-broadcast-tower',
'text': 'fa-font'
}[item.type] || 'fa-file';
const typeLabel = {
'image': 'Afbeelding',
'video': 'Video',
'livestream': 'Livestream'
'livestream': 'Livestream',
'text': 'Tekst'
}[item.type] || 'Bestand';
return `
@@ -162,6 +187,8 @@ class UIManager {
<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'">` :
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>`
}
</div>
@@ -184,10 +211,19 @@ class UIManager {
}
// Modal Management
openContentModal() {
async openContentModal() {
const modal = document.getElementById('contentModal');
if (!modal) {
console.error('Content modal not found');
return;
}
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() {
@@ -206,7 +242,17 @@ class UIManager {
// Reset forms
document.getElementById('contentUploadForm')?.reset();
document.getElementById('scheduleForm')?.reset();
document.getElementById('zoneForm')?.reset();
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
@@ -232,39 +278,85 @@ class UIManager {
}
}
handleContentTypeChange(type) {
const fileUploadGroup = document.getElementById('fileUploadGroup');
const textContentGroup = document.getElementById('textContentGroup');
const contentFile = document.getElementById('contentFile');
const textContent = document.getElementById('textContent');
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 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');
if (!type) {
this.showToast('Selecteer een type', '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.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('title', title);
formData.append('type', type);
formData.append('zone', zone);
formData.append('duration', duration);
const result = await api.uploadContent(formData);
}
this.closeModals();
this.clearContentCache();
await this.loadContent();
this.showToast('Content succesvol geüpload!', 'success');
this.showToast('Content succesvol opgeslagen!', 'success');
} catch (error) {
console.error('Upload error:', error);
this.showToast('Upload mislukt: ' + error.message, 'error');
this.showToast('Opslaan mislukt: ' + error.message, 'error');
} finally {
this.hideLoading();
}
@@ -368,6 +460,50 @@ class UIManager {
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
async loadZones() {
if (this.zonesCache) return this.zonesCache;
@@ -382,13 +518,32 @@ class UIManager {
}
async loadZonesSelect(selectId) {
const zones = await this.loadZones();
const select = document.getElementById(selectId);
if (!select) return;
try {
const zones = await this.loadZones();
const select = document.getElementById(selectId);
if (!select) {
console.error(`Select element with id '${selectId}' not found`);
return;
}
select.innerHTML = zones.map(zone =>
`<option value="${zone.id}">${zone.name}</option>`
).join('');
if (!zones || zones.length === 0) {
console.error('No zones loaded');
select.innerHTML = '<option value="">Geen zones beschikbaar</option>';
return;
}
select.innerHTML = zones.map(zone =>
`<option value="${zone.id}">${zone.name}</option>`
).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() {
@@ -410,19 +565,10 @@ class UIManager {
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>
<i class="fas ${zone.icon || 'fa-map-marker-alt'} fa-3x"></i>
</div>
<h3 class="zone-name">${zone.name}</h3>
<p class="zone-description">${zone.description}</p>
@@ -430,6 +576,32 @@ class UIManager {
`).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
async loadAnalytics() {
try {
@@ -564,4 +736,23 @@ class UIManager {
}
// 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 {
0% { opacity: 1; }
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Navigation Tabs */
@@ -108,33 +107,32 @@ body {
display: flex;
background: var(--light-color);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
padding: 0 2rem;
}
.nav-tab {
padding: 1rem 1.5rem;
background: none;
border: none;
padding: 1rem 1.5rem;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
border-bottom: 3px solid transparent;
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);
background: var(--secondary-color);
}
.nav-tab.active {
background: white;
color: var(--primary-color);
border-bottom: 3px solid var(--primary-color);
font-weight: 500;
border-bottom-color: var(--primary-color);
background: white;
}
/* Main Content */
@@ -150,34 +148,33 @@ body {
display: block;
}
/* Section Header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.section-header h2 {
color: var(--dark-color);
font-size: 1.8rem;
font-weight: 500;
font-weight: 400;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
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 {
@@ -191,14 +188,13 @@ body {
}
.btn-secondary {
background: var(--secondary-color);
color: var(--primary-color);
border: 1px solid var(--primary-color);
background: var(--light-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--primary-color);
color: white;
background: var(--border-color);
}
.btn-danger {
@@ -212,39 +208,7 @@ body {
.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;
font-size: 0.875rem;
}
/* Filter Controls */
@@ -252,11 +216,29 @@ body {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
align-items: center;
}
.filter-controls .form-select {
min-width: 200px;
.form-select,
.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 */
@@ -271,8 +253,8 @@ body {
border-radius: var(--border-radius);
box-shadow: var(--shadow);
overflow: hidden;
transition: var(--transition);
border: 1px solid var(--border-color);
transition: var(--transition);
}
.content-item:hover {
@@ -286,7 +268,6 @@ body {
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
@@ -296,24 +277,63 @@ body {
object-fit: cover;
}
.content-preview.video {
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
.content-preview i {
color: var(--text-secondary);
opacity: 0.6;
}
.content-preview.video::before {
content: '▶';
font-size: 3rem;
.content-preview.video {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.content-preview.video i {
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 {
padding: 1.5rem;
padding: 1rem;
}
.content-title {
font-size: 1.1rem;
font-weight: 600;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--dark-color);
}
@@ -321,10 +341,16 @@ body {
.content-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.content-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.content-actions {
@@ -332,6 +358,18 @@ body {
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 {
display: none;
@@ -342,19 +380,18 @@ body {
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(5px);
align-items: center;
justify-content: center;
}
.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);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 90vh;
@@ -391,100 +428,79 @@ body {
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 {
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 {
/* Schedule Styles */
.schedule-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
}
.zone-selector {
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);
padding: 1.5rem;
margin-top: 1rem;
min-height: 400px;
}
.schedule-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: white;
border-left: 4px solid var(--primary-color);
background: var(--light-color);
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;
font-weight: 500;
color: var(--primary-color);
min-width: 150px;
}
.schedule-content {
flex: 1;
margin-left: 1rem;
min-width: 120px;
}
.schedule-content h4 {
margin-bottom: 0.25rem;
color: var(--dark-color);
margin: 0 0 0.25rem 0;
}
.schedule-content p {
margin: 0;
font-size: 0.875rem;
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 */
@@ -497,10 +513,10 @@ body {
.zone-card {
background: white;
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
padding: 2rem;
text-align: center;
border: 1px solid var(--border-color);
transition: var(--transition);
}
@@ -510,57 +526,114 @@ body {
}
.zone-icon {
font-size: 3rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.zone-icon i {
color: var(--primary-color);
}
.zone-name {
font-size: 1.2rem;
font-weight: 600;
color: var(--dark-color);
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--dark-color);
}
.zone-description {
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-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1100;
top: 2rem;
right: 2rem;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
gap: 0.5rem;
}
.toast {
background: white;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
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;
border-radius: var(--border-radius);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
animation: slideIn 0.3s ease;
max-width: 400px;
}
.toast.success {
background: var(--success-color);
color: white;
}
.toast.error {
border-left-color: var(--danger-color);
}
.toast.warning {
border-left-color: var(--warning-color);
background: var(--danger-color);
color: white;
}
.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 {
transform: translateX(100%);
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 */
@media (max-width: 768px) {
.container {
@@ -580,30 +693,24 @@ body {
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.nav-tabs {
flex-wrap: wrap;
}
.nav-tab {
flex: 1;
min-width: 150px;
padding: 0 1rem;
}
.main-content {
padding: 1rem;
}
.section-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.filter-controls {
flex-direction: column;
align-items: stretch;
}
.schedule-container {
grid-template-columns: 1fr;
}
.content-grid {
@@ -615,8 +722,8 @@ body {
}
.modal-content {
width: 95%;
margin: 1rem;
max-width: none;
}
}
@@ -663,4 +770,46 @@ body {
.analytics-card {
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,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'fa-map-marker-alt',
displayOrder INTEGER DEFAULT 0,
isActive INTEGER DEFAULT 1
)
@@ -82,23 +83,23 @@ class DatabaseManager {
this.db.run(zonesTable);
this.db.run(logsTable);
// Insert default zones
// Insert default zones with icons
const defaultZones = [
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', displayOrder: 1 },
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', displayOrder: 2 },
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', displayOrder: 3 },
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', displayOrder: 4 },
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', displayOrder: 5 },
{ id: 'all', name: 'Alle zones', description: 'Toon op alle schermen', displayOrder: 0 }
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', icon: 'fa-door-open', displayOrder: 1 },
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', icon: 'fa-utensils', displayOrder: 2 },
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', icon: 'fa-skiing', displayOrder: 3 },
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', icon: 'fa-locker', displayOrder: 4 },
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', icon: 'fa-shopping-bag', displayOrder: 5 },
{ id: 'all', name: 'Alle zones', description: 'Toon op alle schermen', icon: 'fa-globe', displayOrder: 0 }
];
const stmt = this.db.prepare(`
INSERT OR IGNORE INTO zones (id, name, description, displayOrder)
VALUES (?, ?, ?, ?)
INSERT OR IGNORE INTO zones (id, name, description, icon, displayOrder)
VALUES (?, ?, ?, ?, ?)
`);
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();
@@ -111,22 +112,57 @@ class DatabaseManager {
async addContent(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, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO content (id, type, title, filename, originalName, mimeType, size, path, url, zone, duration, textContent, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
contentData.id,
contentData.type,
contentData.title,
contentData.filename,
contentData.originalName,
contentData.mimeType,
contentData.size,
contentData.path,
contentData.url,
contentData.filename || '',
contentData.originalName || '',
contentData.mimeType || '',
contentData.size || 0,
contentData.path || '',
contentData.url || '',
contentData.zone,
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,
function(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
async addLog(type, message, data = null) {
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) => {
try {
const { zone, type } = req.query;
@@ -172,16 +205,44 @@ app.get('/api/schedule/:zone', async (req, res) => {
}
});
app.get('/api/zones', (req, res) => {
const zones = [
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie' },
{ 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);
app.get('/api/zones', async (req, res) => {
try {
const zones = await dbManager.getZones();
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

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) {
try {
return await this.db.getContent(zone, type);
@@ -92,7 +103,8 @@ class ContentManager {
const allowedTypes = {
'image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'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)) {
@@ -109,7 +121,8 @@ class ContentManager {
const defaultDurations = {
'image': 10,
'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)

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>

View File

@@ -7,15 +7,29 @@ class DisplayManager {
this.transitionDuration = 1000; // 1 second
this.isPlaying = false;
this.zone = this.getZoneFromURL() || 'reception';
this.zoneData = null;
this.init();
}
init() {
async init() {
this.setupEventListeners();
await this.loadZoneData();
this.updateZoneDisplay();
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() {
// Handle visibility change (tab switching)
document.addEventListener('visibilitychange', () => {
@@ -44,12 +58,25 @@ class DisplayManager {
updateZoneDisplay() {
const zoneElement = document.getElementById('currentZone');
const zoneIconElement = document.querySelector('#zoneIndicator .zone-info i');
if (zoneElement) {
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) {
// Use zone data from server if available
if (this.zoneData && this.zoneData.name) {
return this.zoneData.name;
}
// Fallback to hardcoded names
const zoneNames = {
'reception': 'Receptie',
'restaurant': 'Restaurant',
@@ -167,6 +194,15 @@ class DisplayManager {
`;
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:
element.innerHTML = `
<div class="content-placeholder">
@@ -335,10 +371,11 @@ class DisplayManager {
this.loadContent(newContent);
}
setZone(zone) {
async setZone(zone) {
if (this.zone !== zone) {
console.log(`Zone changed from ${this.zone} to ${zone}`);
this.zone = zone;
await this.loadZoneData();
this.updateZoneDisplay();
// Request new content for this zone

View File

@@ -166,6 +166,37 @@ body {
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 {
font-size: 6rem;
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: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: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-down": "cd deployment/docker && docker compose down",
"docker:compose-logs": "cd deployment/docker && docker compose logs -f"