From 10b9ba4e61950813a16e2dd6b765d0d39eba7214 Mon Sep 17 00:00:00 2001 From: Alvin-Zilverstand <524715@vistacollege.nl> Date: Mon, 9 Feb 2026 10:59:59 +0100 Subject: [PATCH] yes --- admin/index.html | 122 ++++++- admin/js/api.js | 14 + admin/js/ui.js | 265 +++++++++++++-- admin/styles.css | 505 ++++++++++++++++++---------- backend/database/DatabaseManager.js | 99 +++++- backend/server.js | 81 ++++- backend/services/ContentManager.js | 17 +- client/index.html | 1 + client/js/display.js | 41 ++- client/styles.css | 31 ++ docker-compose.yml | 63 ++++ package.json | 2 +- 12 files changed, 991 insertions(+), 250 deletions(-) create mode 100644 docker-compose.yml diff --git a/admin/index.html b/admin/index.html index 53f94ba..17a1331 100644 --- a/admin/index.html +++ b/admin/index.html @@ -4,6 +4,7 @@ SnowWorld - Narrowcasting Admin Dashboard + @@ -65,6 +66,7 @@ + @@ -102,6 +104,9 @@

Zone Overzicht

+
@@ -161,6 +166,7 @@ +
@@ -176,12 +182,19 @@
-
+ +
- +
+ + +
+ + +
diff --git a/admin/js/api.js b/admin/js/api.js index 59a338d..f85a68f 100644 --- a/admin/js/api.js +++ b/admin/js/api.js @@ -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'); diff --git a/admin/js/ui.js b/admin/js/ui.js index 4bee15c..a51e46e 100644 --- a/admin/js/ui.js +++ b/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 {
${item.type === 'image' ? `${item.title}` : + item.type === 'text' ? + `

${item.textContent ? item.textContent.substring(0, 50) + '...' : 'Tekst content'}

` : `` }
@@ -184,10 +211,19 @@ class UIManager { } // Modal Management - openContentModal() { + async openContentModal() { const modal = document.getElementById('contentModal'); + if (!modal) { + console.error('Content modal not found'); + return; + } modal.classList.add('active'); - this.loadZonesSelect('contentZone'); + + // Reset form fields visibility before loading zones + this.handleContentTypeChange(''); + + // Load zones into dropdown + await this.loadZonesSelect('contentZone'); } openScheduleModal() { @@ -206,7 +242,17 @@ class UIManager { // Reset forms document.getElementById('contentUploadForm')?.reset(); document.getElementById('scheduleForm')?.reset(); + document.getElementById('zoneForm')?.reset(); document.getElementById('fileInfo').innerHTML = ''; + + // Reset text content field + const textContent = document.getElementById('textContent'); + if (textContent) { + textContent.value = ''; + } + + // Reset form fields visibility + this.handleContentTypeChange(''); } // Content Upload @@ -232,39 +278,85 @@ class UIManager { } } + handleContentTypeChange(type) { + const fileUploadGroup = document.getElementById('fileUploadGroup'); + const textContentGroup = document.getElementById('textContentGroup'); + const contentFile = document.getElementById('contentFile'); + const textContent = document.getElementById('textContent'); + + if (type === 'text') { + if (fileUploadGroup) fileUploadGroup.style.display = 'none'; + if (textContentGroup) textContentGroup.style.display = 'block'; + if (contentFile) contentFile.removeAttribute('required'); + if (textContent) textContent.setAttribute('required', 'required'); + } else { + if (fileUploadGroup) fileUploadGroup.style.display = 'block'; + if (textContentGroup) textContentGroup.style.display = 'none'; + if (contentFile) contentFile.setAttribute('required', 'required'); + if (textContent) textContent.removeAttribute('required'); + } + } + async uploadContent() { - const form = document.getElementById('contentUploadForm'); - const formData = new FormData(); - - const fileInput = document.getElementById('contentFile'); const title = document.getElementById('contentTitle').value; const type = document.getElementById('contentType').value; const zone = document.getElementById('contentZone').value; const duration = document.getElementById('contentDuration').value; - if (!fileInput.files[0]) { - this.showToast('Selecteer een bestand', 'error'); + if (!type) { + this.showToast('Selecteer een type', 'error'); return; } - formData.append('content', fileInput.files[0]); - formData.append('title', title); - formData.append('type', type); - formData.append('zone', zone); - formData.append('duration', duration); - try { - this.showLoading('Bezig met uploaden...'); - const result = await api.uploadContent(formData); + this.showLoading('Bezig met opslaan...'); + + if (type === 'text') { + // Handle text content + const textContent = document.getElementById('textContent').value; + + if (!textContent.trim()) { + this.showToast('Voer tekst in', 'error'); + this.hideLoading(); + return; + } + + const textData = { + title: title, + textContent: textContent, + zone: zone, + duration: parseInt(duration) + }; + + const result = await api.createTextContent(textData); + } else { + // Handle file upload + const fileInput = document.getElementById('contentFile'); + + if (!fileInput.files[0]) { + this.showToast('Selecteer een bestand', 'error'); + this.hideLoading(); + return; + } + + const formData = new FormData(); + formData.append('content', fileInput.files[0]); + formData.append('title', title); + formData.append('type', type); + formData.append('zone', zone); + formData.append('duration', duration); + + const result = await api.uploadContent(formData); + } this.closeModals(); this.clearContentCache(); await this.loadContent(); - this.showToast('Content succesvol geüpload!', 'success'); + this.showToast('Content succesvol opgeslagen!', 'success'); } catch (error) { console.error('Upload error:', error); - this.showToast('Upload mislukt: ' + error.message, 'error'); + this.showToast('Opslaan mislukt: ' + error.message, 'error'); } finally { this.hideLoading(); } @@ -368,6 +460,50 @@ class UIManager { document.getElementById('scheduleEnd').value = endTime.toISOString().slice(0, 16); } + async createZone() { + const zoneId = document.getElementById('zoneId').value.trim(); + const zoneName = document.getElementById('zoneName').value.trim(); + const zoneDescription = document.getElementById('zoneDescription').value.trim(); + const zoneDisplayOrder = document.getElementById('zoneDisplayOrder').value; + const zoneIcon = document.getElementById('zoneIcon').value; + + if (!zoneId || !zoneName) { + this.showToast('Zone ID en naam zijn verplicht', 'error'); + return; + } + + // Validate zone ID format (lowercase letters, numbers, hyphens only) + const validIdPattern = /^[a-z0-9-]+$/; + if (!validIdPattern.test(zoneId)) { + this.showToast('Zone ID mag alleen kleine letters, cijfers en streepjes bevatten', 'error'); + return; + } + + const zoneData = { + id: zoneId, + name: zoneName, + description: zoneDescription, + icon: zoneIcon, + displayOrder: parseInt(zoneDisplayOrder) || 0 + }; + + try { + this.showLoading('Bezig met toevoegen...'); + await api.createZone(zoneData); + + this.closeModals(); + this.zonesCache = null; // Clear cache + await this.loadZonesOverview(); + + this.showToast('Zone succesvol toegevoegd!', 'success'); + } catch (error) { + console.error('Zone creation error:', error); + this.showToast('Zone toevoegen mislukt: ' + error.message, 'error'); + } finally { + this.hideLoading(); + } + } + // Zones Management async loadZones() { if (this.zonesCache) return this.zonesCache; @@ -382,13 +518,32 @@ class UIManager { } async loadZonesSelect(selectId) { - const zones = await this.loadZones(); - const select = document.getElementById(selectId); - if (!select) return; + try { + const zones = await this.loadZones(); + const select = document.getElementById(selectId); + if (!select) { + console.error(`Select element with id '${selectId}' not found`); + return; + } - select.innerHTML = zones.map(zone => - `` - ).join(''); + if (!zones || zones.length === 0) { + console.error('No zones loaded'); + select.innerHTML = ''; + return; + } + + select.innerHTML = zones.map(zone => + `` + ).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 = ''; + } + } } 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 => `
- +

${zone.name}

${zone.description}

@@ -430,6 +576,32 @@ class UIManager { `).join(''); } + selectIcon(iconName) { + // Update hidden input + document.getElementById('zoneIcon').value = iconName; + + // Update visual selection + document.querySelectorAll('.icon-option').forEach(option => { + option.classList.remove('selected'); + if (option.dataset.icon === iconName) { + option.classList.add('selected'); + } + }); + } + + openZoneModal() { + const modal = document.getElementById('zoneModal'); + if (!modal) { + console.error('Zone modal not found'); + return; + } + modal.classList.add('active'); + + // Reset icon selection to default + this.selectIcon('fa-map-marker-alt'); + } + } + // Analytics async loadAnalytics() { try { @@ -564,4 +736,23 @@ class UIManager { } // Create global UI instance -window.ui = new UIManager(); \ No newline at end of file +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(); + } +}; \ No newline at end of file diff --git a/admin/styles.css b/admin/styles.css index 3996631..a7ae057 100644 --- a/admin/styles.css +++ b/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; } } @@ -663,4 +770,46 @@ body { .analytics-card { border-top: 3px solid var(--accent-color); -} \ No newline at end of file +} + +/* 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); + } +} diff --git a/backend/database/DatabaseManager.js b/backend/database/DatabaseManager.js index 1fd67db..b089b02 100644 --- a/backend/database/DatabaseManager.js +++ b/backend/database/DatabaseManager.js @@ -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) => { diff --git a/backend/server.js b/backend/server.js index 9f53d5e..abc9174 100644 --- a/backend/server.js +++ b/backend/server.js @@ -95,6 +95,39 @@ app.post('/api/content/upload', upload.single('content'), async (req, res) => { } }); +// Text Content Management +app.post('/api/content/text', async (req, res) => { + try { + const { title, textContent, zone, duration } = req.body; + + if (!title || !textContent) { + return res.status(400).json({ error: 'Title and text content are required' }); + } + + const contentData = { + id: uuidv4(), + title: title, + textContent: textContent, + zone: zone || 'all', + duration: parseInt(duration) || 15, + createdAt: new Date().toISOString() + }; + + const content = await contentManager.addTextContent(contentData); + + // Emit real-time update + io.emit('contentUpdated', { + type: 'content_added', + content: content + }); + + res.json({ success: true, content }); + } catch (error) { + console.error('Text content creation error:', error); + res.status(500).json({ error: 'Failed to create text content' }); + } +}); + app.get('/api/content', async (req, res) => { try { const { zone, type } = req.query; @@ -172,16 +205,44 @@ app.get('/api/schedule/:zone', async (req, res) => { } }); -app.get('/api/zones', (req, res) => { - const zones = [ - { id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie' }, - { id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid' }, - { id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan' }, - { id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes' }, - { id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel' }, - { id: 'all', name: 'Alle zones', description: 'Toon op alle schermen' } - ]; - res.json(zones); +app.get('/api/zones', async (req, res) => { + try { + const zones = await dbManager.getZones(); + res.json(zones); + } catch (error) { + console.error('Get zones error:', error); + res.status(500).json({ error: 'Failed to retrieve zones' }); + } +}); + +app.post('/api/zones', async (req, res) => { + try { + const { id, name, description, icon, displayOrder } = req.body; + + if (!id || !name) { + return res.status(400).json({ error: 'Zone ID and name are required' }); + } + + const zoneData = { + id: id.toLowerCase().replace(/\s+/g, '-'), + name: name, + description: description || '', + icon: icon || 'fa-map-marker-alt', + displayOrder: parseInt(displayOrder) || 0 + }; + + const zone = await dbManager.addZone(zoneData); + + io.emit('zonesUpdated', { + type: 'zone_added', + zone: zone + }); + + res.json({ success: true, zone }); + } catch (error) { + console.error('Create zone error:', error); + res.status(500).json({ error: 'Failed to create zone' }); + } }); // Weather widget data diff --git a/backend/services/ContentManager.js b/backend/services/ContentManager.js index 1aef60c..007c72e 100644 --- a/backend/services/ContentManager.js +++ b/backend/services/ContentManager.js @@ -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) diff --git a/client/index.html b/client/index.html index dae1740..0e697e5 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,7 @@ SnowWorld - Narrowcasting Display + diff --git a/client/js/display.js b/client/js/display.js index 8747e55..08cd402 100644 --- a/client/js/display.js +++ b/client/js/display.js @@ -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 = ` +
+

${contentItem.title}

+
${contentItem.textContent}
+
+ `; + break; + default: element.innerHTML = `
@@ -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 diff --git a/client/styles.css b/client/styles.css index 2ab14f6..9bb3293 100644 --- a/client/styles.css +++ b/client/styles.css @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14cab03 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index 276fd16..ace7ff0 100644 --- a/package.json +++ b/package.json @@ -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"