mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 02:57:17 +01:00
yes
This commit is contained in:
122
admin/index.html
122
admin/index.html
@@ -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">×</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 -->
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
265
admin/js/ui.js
265
admin/js/ui.js
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async uploadContent() {
|
||||||
const form = document.getElementById('contentUploadForm');
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
const fileInput = document.getElementById('contentFile');
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('content', fileInput.files[0]);
|
|
||||||
formData.append('title', title);
|
|
||||||
formData.append('type', type);
|
|
||||||
formData.append('zone', zone);
|
|
||||||
formData.append('duration', duration);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.showLoading('Bezig met uploaden...');
|
this.showLoading('Bezig met opslaan...');
|
||||||
const result = await api.uploadContent(formData);
|
|
||||||
|
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.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) {
|
||||||
const zones = await this.loadZones();
|
try {
|
||||||
const select = document.getElementById(selectId);
|
const zones = await this.loadZones();
|
||||||
if (!select) return;
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) {
|
||||||
|
console.error(`Select element with id '${selectId}' not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
select.innerHTML = zones.map(zone =>
|
if (!zones || zones.length === 0) {
|
||||||
`<option value="${zone.id}">${zone.name}</option>`
|
console.error('No zones loaded');
|
||||||
).join('');
|
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() {
|
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 {
|
||||||
@@ -564,4 +736,23 @@ 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
505
admin/styles.css
505
admin/styles.css
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,4 +770,46 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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' },
|
res.json(zones);
|
||||||
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan' },
|
} catch (error) {
|
||||||
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes' },
|
console.error('Get zones error:', error);
|
||||||
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel' },
|
res.status(500).json({ error: 'Failed to retrieve zones' });
|
||||||
{ id: 'all', name: 'Alle zones', description: 'Toon op alle schermen' }
|
}
|
||||||
];
|
});
|
||||||
res.json(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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
63
docker-compose.yml
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user