mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 11:07:14 +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 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">×</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 -->
|
||||
|
||||
@@ -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');
|
||||
|
||||
241
admin/js/ui.js
241
admin/js/ui.js
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadContent() {
|
||||
const form = document.getElementById('contentUploadForm');
|
||||
const formData = new FormData();
|
||||
handleContentTypeChange(type) {
|
||||
const fileUploadGroup = document.getElementById('fileUploadGroup');
|
||||
const textContentGroup = document.getElementById('textContentGroup');
|
||||
const contentFile = document.getElementById('contentFile');
|
||||
const textContent = document.getElementById('textContent');
|
||||
|
||||
const fileInput = document.getElementById('contentFile');
|
||||
if (type === 'text') {
|
||||
if (fileUploadGroup) fileUploadGroup.style.display = 'none';
|
||||
if (textContentGroup) textContentGroup.style.display = 'block';
|
||||
if (contentFile) contentFile.removeAttribute('required');
|
||||
if (textContent) textContent.setAttribute('required', 'required');
|
||||
} else {
|
||||
if (fileUploadGroup) fileUploadGroup.style.display = 'block';
|
||||
if (textContentGroup) textContentGroup.style.display = 'none';
|
||||
if (contentFile) contentFile.setAttribute('required', 'required');
|
||||
if (textContent) textContent.removeAttribute('required');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadContent() {
|
||||
const title = document.getElementById('contentTitle').value;
|
||||
const 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;
|
||||
}
|
||||
|
||||
try {
|
||||
this.showLoading('Bezig met opslaan...');
|
||||
|
||||
if (type === 'text') {
|
||||
// Handle text content
|
||||
const textContent = document.getElementById('textContent').value;
|
||||
|
||||
if (!textContent.trim()) {
|
||||
this.showToast('Voer tekst in', 'error');
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
const textData = {
|
||||
title: title,
|
||||
textContent: textContent,
|
||||
zone: zone,
|
||||
duration: parseInt(duration)
|
||||
};
|
||||
|
||||
const result = await api.createTextContent(textData);
|
||||
} else {
|
||||
// Handle file upload
|
||||
const fileInput = document.getElementById('contentFile');
|
||||
|
||||
if (!fileInput.files[0]) {
|
||||
this.showToast('Selecteer een bestand', 'error');
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', fileInput.files[0]);
|
||||
formData.append('title', title);
|
||||
formData.append('type', type);
|
||||
formData.append('zone', zone);
|
||||
formData.append('duration', duration);
|
||||
|
||||
try {
|
||||
this.showLoading('Bezig met uploaden...');
|
||||
const result = await api.uploadContent(formData);
|
||||
}
|
||||
|
||||
this.closeModals();
|
||||
this.clearContentCache();
|
||||
await this.loadContent();
|
||||
|
||||
this.showToast('Content succesvol geüpload!', 'success');
|
||||
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) {
|
||||
try {
|
||||
const zones = await this.loadZones();
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (!select) {
|
||||
console.error(`Select element with id '${selectId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zones || zones.length === 0) {
|
||||
console.error('No zones loaded');
|
||||
select.innerHTML = '<option value="">Geen zones beschikbaar</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = zones.map(zone =>
|
||||
`<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 {
|
||||
@@ -565,3 +737,22 @@ class UIManager {
|
||||
|
||||
// Create global UI instance
|
||||
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();
|
||||
}
|
||||
};
|
||||
503
admin/styles.css
503
admin/styles.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,3 +771,45 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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: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"
|
||||
|
||||
Reference in New Issue
Block a user