From acfc50a24432cedb37be01b5fd9af6e831995086 Mon Sep 17 00:00:00 2001 From: patrickzerhusen Date: Thu, 30 Apr 2026 15:57:56 +0200 Subject: [PATCH] added admin.js for javascript refractoring of moderation portal --- public/js/admin.js | 665 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) diff --git a/public/js/admin.js b/public/js/admin.js index e69de29..33f9b89 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -0,0 +1,665 @@ +// ===================================================================== +// WebGIS Moderation Portal — Application Logic +// Initializes Map Preview, loads Contributions from the API, +// handles CRUD Workflow, sorting and filtering for Contributions, +// Comments and News, and manages all UI Interactions +// +// Depends on: ADMIN_CONFIG Object set in Moderation Page +// ===================================================================== + + +// ===================================================================== +// Block 1: Status Filter for Contributions +// ===================================================================== +let currentFilter = 'all'; + + +// ===================================================================== +// Restores active Tab after Page Reload +// ===================================================================== +const savedTab = sessionStorage.getItem('admin_active_tab'); +if (savedTab) { + // Delays to ensure DOM is ready + setTimeout(function () { + const tabBtn = document.querySelector('.page-tab[onclick*="' + savedTab + '"]'); + if (tabBtn) tabBtn.click(); + }, 100); +} + + +// ===================================================================== +// Block 3: Page Tab Navigation +// ===================================================================== + +// Wechselt den sichtbaren Haupt-Tab (Beiträge, Kommentare, Neuigkeiten usw.). +// Speichert den aktiven Tab in sessionStorage für Persistenz nach Reload. +function showPageTab(tabName) { + + sessionStorage.setItem('admin_active_tab', tabName); + + document.querySelectorAll('.page-tab-content').forEach(function (el) { + el.style.display = 'none'; + }); + + document.querySelectorAll('.page-tab').forEach(function (el) { + el.classList.remove('active'); + }); + + document.getElementById('tab-' + tabName).style.display = 'block'; + event.currentTarget.classList.add('active'); +} + + +// ===================================================================== +// Block 4: Aufklappbare Zeilen (Collapsible Rows) +// ===================================================================== + +// Öffnet oder schließt eine Beitrags-/Kommentar-Zeile. +// Jeweils nur eine Zeile gleichzeitig geöffnet. +// Lädt bei Bedarf die Karten-Vorschau (Map Preview). +function toggleRow(row) { + const wasOpen = row.classList.contains('open'); + + // Alle offenen Zeilen schließen + document.querySelectorAll('.contribution-row.open').forEach(function (el) { + el.classList.remove('open'); + }); + + if (!wasOpen) { + row.classList.add('open'); + + // Karten-Vorschau laden, falls noch nicht geschehen + const mapDiv = row.querySelector('.detail-map'); + if (mapDiv && !mapDiv.dataset.loaded) { + loadMapPreview(mapDiv); + } + } +} + + +// ===================================================================== +// Block 5: Detail-Slider (Karte / Foto) +// ===================================================================== + +// Wechselt zwischen den Slides (Karte und Foto) in einer Beitrags-Zeile. +// direction: -1 für zurück, +1 für vor. +// Wrapped circular: nach dem letzten Slide kommt der erste. +function slideDetail(contributionId, direction) { + const slider = document.getElementById('slider-' + contributionId); + if (!slider) return; + + const slides = slider.querySelectorAll('.detail-slide'); + let activeIndex = -1; + + slides.forEach(function (slide, i) { + if (slide.style.display !== 'none') activeIndex = i; + }); + + const nextIndex = (activeIndex + direction + slides.length) % slides.length; + + slides.forEach(function (slide) { slide.style.display = 'none'; }); + slides[nextIndex].style.display = 'block'; + + // Karte beim Zurückwechseln zum Map-Slide laden, falls noch nicht geschehen + if (slides[nextIndex].dataset.slide === 'map') { + const mapDiv = slides[nextIndex].querySelector('.detail-map'); + if (mapDiv && !mapDiv.dataset.loaded) { + loadMapPreview(mapDiv); + } + } +} + + +// ===================================================================== +// Block 6: Leaflet Karten-Vorschau (Mini-Map pro Beitrag) +// ===================================================================== + +// Erstellt eine Leaflet-Mini-Map in einem Beitrags-Detail-Container. +// Lädt alle Beiträge via API und zeigt die Geometrie des entsprechenden Beitrags. +// Markiert die Map als geladen (data-loaded="true"), um doppeltes Laden zu verhindern. +function loadMapPreview(mapDiv) { + const contributionId = mapDiv.dataset.contributionId; + + const formData = new FormData(); + formData.append('action', 'read'); + formData.append('municipality_id', ADMIN_CONFIG.municipalityId); + formData.append('status', 'all'); + + fetch(ADMIN_CONFIG.apiUrl, { method: 'POST', body: formData }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.features) return; + + const feature = data.features.find(function (f) { + return f.properties.contribution_id == contributionId; + }); + + if (!feature) { + mapDiv.innerHTML = '
Geometrie nicht gefunden.
'; + return; + } + + const miniMap = L.map(mapDiv, { + zoomControl: false, + attributionControl: false, + dragging: true, + scrollWheelZoom: false + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + maxZoom: 20 + }).addTo(miniMap); + + const geojsonLayer = L.geoJSON(feature, { + style: { + color: ADMIN_CONFIG.primaryColor, + weight: 3, + fillOpacity: 0.2 + }, + pointToLayer: function (f, latlng) { + return L.circleMarker(latlng, { + radius: 8, + color: '#ffffff', + weight: 2, + fillColor: ADMIN_CONFIG.primaryColor, + fillOpacity: 0.9 + }); + } + }).addTo(miniMap); + + const bounds = geojsonLayer.getBounds(); + if (bounds.isValid()) { + miniMap.fitBounds(bounds, { padding: [25, 25], maxZoom: 17 }); + } else { + miniMap.setView(ADMIN_CONFIG.municipalityCenter, 15); + } + + mapDiv.dataset.loaded = 'true'; + }) + .catch(function () { + mapDiv.innerHTML = '
Karte nicht verfügbar.
'; + }); +} + + +// ===================================================================== +// Block 7: Beitrags-Filter und Sortierung +// ===================================================================== + +// Filtert Beitrags-Zeilen nach Status (pending, approved, rejected, all). +// Aktualisiert die sichtbare Anzahl im Sort-Control. +function filterByStatus(status, tabButton) { + currentFilter = status; + + document.querySelectorAll('.filter-tab').forEach(function (el) { + el.classList.remove('active'); + }); + tabButton.classList.add('active'); + + let visibleCount = 0; + document.querySelectorAll('#contributions-container .contribution-row').forEach(function (row) { + if (status === 'all' || row.dataset.status === status) { + row.style.display = ''; + visibleCount++; + } else { + row.style.display = 'none'; + } + }); + + document.getElementById('visible-count').textContent = visibleCount + ' Beiträge'; +} + +// Sortiert Beitrags-Zeilen im DOM nach Datum (auf-/absteigend) oder Kategorie. +function sortContributions(sortBy) { + const container = document.getElementById('contributions-container'); + const rows = Array.from(container.querySelectorAll('.contribution-row')); + + rows.sort(function (a, b) { + if (sortBy === 'date-desc') return new Date(b.dataset.date) - new Date(a.dataset.date); + if (sortBy === 'date-asc') return new Date(a.dataset.date) - new Date(b.dataset.date); + if (sortBy === 'category') return a.dataset.category.localeCompare(b.dataset.category); + return 0; + }); + + rows.forEach(function (row) { container.appendChild(row); }); +} + + +// ===================================================================== +// Block 8: Kommentar-Filter und Sortierung +// ===================================================================== + +// Filtert Kommentar-Zeilen nach Status (pending, approved, rejected, all). +// Arbeitet ausschließlich auf dem #comment-filter-tabs Container, +// damit keine Kollision mit dem Beitrags-Filter entsteht. +function filterCommentsByStatus(status, tabButton) { + document.querySelectorAll('#comment-filter-tabs .filter-tab').forEach(function (el) { + el.classList.remove('active'); + }); + tabButton.classList.add('active'); + + let visibleCount = 0; + document.querySelectorAll('.comment-mod-row').forEach(function (row) { + if (status === 'all' || row.dataset.status === status) { + row.style.display = ''; + visibleCount++; + } else { + row.style.display = 'none'; + } + }); + + document.getElementById('comment-visible-count').textContent = visibleCount + ' Kommentare'; +} + +// Sortiert Kommentar-Zeilen nach Datum oder Beitrags-Titel. +function sortCommentRows(sortBy) { + const container = document.getElementById('comments-mod-container'); + const rows = Array.from(container.querySelectorAll('.comment-mod-row')); + + rows.sort(function (a, b) { + if (sortBy === 'date-desc') return new Date(b.dataset.date) - new Date(a.dataset.date); + if (sortBy === 'date-asc') return new Date(a.dataset.date) - new Date(b.dataset.date); + if (sortBy === 'contribution') return a.dataset.contribution.localeCompare(b.dataset.contribution); + return 0; + }); + + rows.forEach(function (row) { container.appendChild(row); }); +} + + +// ===================================================================== +// Block 9: API-Hilfsfunktion +// ===================================================================== + +// Sendet einen POST-Request an die API und gibt ein Promise zurück. +// Analog zu apiCall() in app.js, jedoch Promise-basiert statt Callback-basiert, +// da admin.js keine asynchrone Verkettung mit Callback-Chains benötigt. +function apiCall(data) { + const formData = new FormData(); + for (const key in data) { + formData.append(key, data[key]); + } + return fetch(ADMIN_CONFIG.apiUrl, { method: 'POST', body: formData }) + .then(function (r) { return r.json(); }); +} + + +// ===================================================================== +// Block 10: Hilfsfunktion HTML-Escaping +// ===================================================================== + +// Verhindert XSS in dynamisch erzeugten SweetAlert-Dialogen. +// Identisch zu escapeHtml() in app.js — beide Dateien arbeiten unabhängig, +// daher keine gemeinsame Abhängigkeit eingeführt. +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; +} + + +// ===================================================================== +// Block 11: Beitrags-Status ändern +// ===================================================================== + +// Ändert den Status eines Beitrags (approved / rejected / pending). +// Zeigt eine SweetAlert-Bestätigung und lädt die Seite nach Erfolg neu. +function changeStatus(contributionId, newStatus) { + const labels = { approved: 'freigeben', rejected: 'ablehnen', pending: 'zurücksetzen' }; + + Swal.fire({ + title: 'Beitrag ' + labels[newStatus] + '?', + showCancelButton: true, + confirmButtonText: 'Ja', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update', + contribution_id: contributionId, + status: newStatus + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + location.reload(); + }); + }); +} + + +// ===================================================================== +// Block 12: Beitrag bearbeiten +// ===================================================================== + +// Öffnet einen SweetAlert-Dialog zum Bearbeiten von Titel und Beschreibung. +// Werte werden mit escapeHtml() gesichert, bevor sie ins HTML eingefügt werden. +function editContribution(contributionId, currentTitle, currentDescription) { + Swal.fire({ + title: 'Beitrag bearbeiten', + html: + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
', + showCancelButton: true, + confirmButtonText: 'Speichern', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor, + preConfirm: function () { + return { + title: document.getElementById('swal-title').value.trim(), + description: document.getElementById('swal-description').value.trim() + }; + } + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update', + contribution_id: contributionId, + title: result.value.title, + description: result.value.description + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gespeichert!', 'Beitrag wurde aktualisiert.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 13: Beitrag löschen +// ===================================================================== + +// Zeigt eine Lösch-Bestätigung und entfernt den Beitrag dauerhaft. +function deleteContribution(contributionId) { + Swal.fire({ + title: 'Beitrag löschen?', + text: 'Diese Aktion kann nicht rückgängig gemacht werden.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Beitrag löschen', + cancelButtonText: 'Abbrechen', + confirmButtonColor: '#c62828' + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'delete', + contribution_id: contributionId + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gelöscht!', 'Beitrag wurde gelöscht.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 14: Kommentar-Status ändern +// ===================================================================== + +// Ändert den Status eines Kommentars (approved / rejected / pending). +function changeCommentStatus(commentId, newStatus) { + const labels = { approved: 'akzeptieren', rejected: 'ablehnen', pending: 'zurücksetzen' }; + + Swal.fire({ + title: 'Kommentar ' + labels[newStatus] + '?', + showCancelButton: true, + confirmButtonText: 'Ja', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update_comment', + comment_id: commentId, + status: newStatus + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + location.reload(); + }); + }); +} + + +// ===================================================================== +// Block 15: Kommentar bearbeiten +// ===================================================================== + +// Öffnet einen Dialog zum Bearbeiten des Kommentar-Inhalts. +function editModComment(commentId, currentContent) { + Swal.fire({ + title: 'Kommentar bearbeiten', + html: + '
' + + '' + + '' + + '
', + showCancelButton: true, + confirmButtonText: 'Speichern', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor, + preConfirm: function () { + return { content: document.getElementById('swal-comment-content').value.trim() }; + } + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update_comment', + comment_id: commentId, + content: result.value.content + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gespeichert!', 'Kommentar wurde aktualisiert.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 16: Kommentar löschen +// ===================================================================== + +// Zeigt eine Lösch-Bestätigung und entfernt den Kommentar dauerhaft. +function deleteModComment(commentId) { + Swal.fire({ + title: 'Kommentar löschen?', + text: 'Diese Aktion kann nicht rückgängig gemacht werden.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Löschen', + cancelButtonText: 'Abbrechen', + confirmButtonColor: '#c62828' + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'delete_comment', + comment_id: commentId + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gelöscht!', 'Kommentar wurde entfernt.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 17: Neuigkeit erstellen +// ===================================================================== + +// Öffnet einen Dialog zum Erstellen einer neuen Neuigkeit. +// Titel und Inhalt sind Pflichtfelder, Autor hat einen Standardwert. +function createNews() { + Swal.fire({ + title: 'Neuigkeit hinzufügen', + html: + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
', + showCancelButton: true, + confirmButtonText: 'Veröffentlichen', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor, + preConfirm: function () { + const title = document.getElementById('swal-news-title').value.trim(); + const content = document.getElementById('swal-news-content').value.trim(); + const author = document.getElementById('swal-news-author').value.trim() || 'Stadtverwaltung'; + if (!title || !content) { + Swal.showValidationMessage('Titel und Inhalt sind Pflichtfelder.'); + return false; + } + return { title, content, author_name: author }; + } + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'create_news', + municipality_id: ADMIN_CONFIG.municipalityId, + title: result.value.title, + content: result.value.content, + author_name: result.value.author_name + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Veröffentlicht!', 'Neuigkeit wurde veröffentlicht.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 18: Neuigkeit bearbeiten +// ===================================================================== + +// Öffnet einen Dialog zum Bearbeiten einer bestehenden Neuigkeit. +function editNews(newsId, currentTitle, currentContent, currentAuthor) { + Swal.fire({ + title: 'Neuigkeit bearbeiten', + html: + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
', + showCancelButton: true, + confirmButtonText: 'Speichern', + cancelButtonText: 'Abbrechen', + confirmButtonColor: ADMIN_CONFIG.primaryColor, + preConfirm: function () { + return { + title: document.getElementById('swal-news-title').value.trim(), + content: document.getElementById('swal-news-content').value.trim(), + author_name: document.getElementById('swal-news-author').value.trim() || 'Stadtverwaltung' + }; + } + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update_news', + news_id: newsId, + title: result.value.title, + content: result.value.content, + author_name: result.value.author_name + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gespeichert!', 'Neuigkeit wurde aktualisiert.', 'success') + .then(function () { location.reload(); }); + }); + }); +} + + +// ===================================================================== +// Block 19: Neuigkeit löschen +// ===================================================================== + +// Zeigt eine Lösch-Bestätigung und entfernt die Neuigkeit dauerhaft. +// Hinweis: Im Original-Code war dieser Block fälschlicherweise mit +// "// Create News Article" kommentiert — hier korrigiert. +function deleteNews(newsId) { + Swal.fire({ + title: 'Neuigkeit löschen?', + text: 'Diese Aktion kann nicht rückgängig gemacht werden.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Löschen', + cancelButtonText: 'Abbrechen', + confirmButtonColor: '#c62828' + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'delete_news', + news_id: newsId + }).then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gelöscht!', 'Neuigkeit wurde gelöscht.', 'success') + .then(function () { location.reload(); }); + }); + }); +} \ No newline at end of file