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