// ===================================================================== // 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(); }); }); }); }