// ===================================================================== // WebGIS Citizen Participation Portal — Application Logic // Initializes Leaflet Map, loads Contributions from the API, // handles CRUD Workflow, and manages all UI Interactions. // // Depends on: MUNICIPALITY Object set in Main Page, Leaflet, Geoman, // Sidebar, Geocoder, PolylineMeasure, Fullscreen, // and SweetAlert2 Plugins. // ===================================================================== // ===================================================================== // Block 1: Configuration and Application State // ===================================================================== // API Endpoint as relative Path const API_URL = 'api/contributions.php'; // Username set via Login Modal stored in sessionStorage let currentUser = sessionStorage.getItem('webgis_user') || ''; // Browser Identification Number for anonymous User Identification stored as Cookie let browserId = getBrowserId(); function getBrowserId() { let id = document.cookie.replace(/(?:(?:^|.*;\s*)webgis_browser_id\s*=\s*([^;]*).*$)|^.*$/, '$1'); if (!id) { id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); // Cookie Expiration in one Year document.cookie = 'webgis_browser_id=' + id + ';path=/;max-age=31536000;SameSite=Lax'; } return id; } // Application State let map; // Leaflet Map Instance let sidebar; // Sidebar Instance let contributionsLayer; // GeoJSON Layer holding all Contributions let contributionsData = []; // Raw Contribution Data Array let activeFilters = Object.keys(CATEGORIES); // Active Category Filters let drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman let drawnGeomType = null; // Temporary Storage for Geometry Type let userVotes = {}; // Tracks User Votes // ===================================================================== // Block 2: Map Initialization // ===================================================================== map = L.map('map', { center: MUNICIPALITY.center, zoom: MUNICIPALITY.zoom, minZoom: 10, maxBounds: [ [MUNICIPALITY.center[0] - 0.25, MUNICIPALITY.center[1] - 0.25], [MUNICIPALITY.center[0] + 0.25, MUNICIPALITY.center[1] + 0.25] ], maxBoundsViscosity: 0.8, zoomControl: false, attributionControl: true }); // ===================================================================== // Block 3: Basemaps and Layer Control // ===================================================================== // Basemap Tile Layers const basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 20 }); const basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© Carto', maxZoom: 20 }); const basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri', maxZoom: 20 }); // Set Default Basemap basemapCartoDB.addTo(map); // Layer Control const basemaps = { ' Hintergrundkarte (farbe)': basemapOSM, ' Hintergrundkarte (grau)': basemapCartoDB, ' Satellitenbild': basemapSatellite, }; const overlays = {}; // Populated later with Contribution Layers const layerControl = L.control.layers(basemaps, overlays, { position: 'topright', collapsed: true }).addTo(map); // ===================================================================== // Block 4: Map Controls // ===================================================================== // Zoom Control L.control.zoom({ position: 'topright' }).addTo(map); // Scale Bar L.control.scale({ position: 'bottomright', maxWidth: 200, imperial: false }).addTo(map); // Fullscreen Button L.control.fullscreen({ position: 'topright', title: 'Vollbild', titleCancel: 'Vollbild deaktivieren' }).addTo(map); // Geocoder Address Search L.Control.geocoder({ position: 'topright', placeholder: 'Adresse suchen...', defaultMarkGeocode: true, geocoder: L.Control.Geocoder.nominatim({ geocodingQueryParams: { countrycodes: 'de', viewbox: (MUNICIPALITY.center[1] - 0.3) + ',' + (MUNICIPALITY.center[0] - 0.2) + ',' + (MUNICIPALITY.center[1] + 0.3) + ',' + (MUNICIPALITY.center[0] + 0.2), bounded: 0, } }) }).addTo(map); // Polyline Measure Tool // L.control.polylineMeasure({ // position: 'topright', // unit: 'metres', // showBearings: false, // clearMeasurementsOnStop: false, // showClearControl: true // }).addTo(map); // Mouse Position Display // const MousePositionControl = L.Control.extend({ // options: { position: 'bottomright' }, // onAdd: function () { // const container = L.DomUtil.create('div', 'mouse-position-display'); // container.innerHTML = 'Lat: , Lng: '; // map.on('mousemove', function (e) { // container.innerHTML = 'Lat: ' + e.latlng.lat.toFixed(5) + ', Lng: ' + e.latlng.lng.toFixed(5); // }); // return container; // } // }); // new MousePositionControl().addTo(map); // GPS Location Button const GpsControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); const button = L.DomUtil.create('a', 'gps-control-button', container); button.href = '#'; button.title = 'Mein Standort'; button.innerHTML = ''; L.DomEvent.on(button, 'click', function (e) { L.DomEvent.preventDefault(e); map.locate({ setView: true, maxZoom: 17 }); }); return container; } }); new GpsControl().addTo(map); // GPS Location Found Handler let gpsMarker = null; map.on('locationfound', function (e) { if (gpsMarker) { map.removeLayer(gpsMarker); } gpsMarker = L.circleMarker(e.latlng, { radius: 8, color: '#1565C0', fillColor: '#42A5F5', fillOpacity: 0.8, weight: 2 }).addTo(map).bindPopup('Ihr Standort').openPopup(); }); map.on('locationerror', function () { Swal.fire('Standort nicht gefunden', 'Bitte gestatten Sie den Standortzugriff in Ihrem Browser.', 'warning'); }); // ===================================================================== // Block 5: Sidebar Initialization // ===================================================================== sidebar = L.control.sidebar({ autopan: true, closeButton: true, container: 'sidebar', position: 'left' }).addTo(map); // ===================================================================== // Block 6: Geoman Drawing Tools and CRUD Trigger // ===================================================================== map.pm.addControls({ position: 'topright', drawMarker: true, drawPolyline: true, drawPolygon: true, drawCircleMarker: false, drawCircle: false, drawText: false, drawRectangle: false, editMode: false, dragMode: false, cutPolygon: false, removalMode: false, rotateMode: false }); map.pm.setLang('de'); // Captures drawn Geometry and opens the Create Modal map.on('pm:create', function (e) { const geojson = e.layer.toGeoJSON().geometry; // Determines drawn Geometry Type and normalizes to simple Types if (e.shape === 'Marker') { drawnGeometry = { type: 'Point', coordinates: geojson.coordinates }; drawnGeomType = 'point'; } else if (e.shape === 'Line') { drawnGeometry = { type: 'LineString', coordinates: geojson.coordinates }; drawnGeomType = 'line'; } else if (e.shape === 'Polygon') { drawnGeometry = { type: 'Polygon', coordinates: geojson.coordinates }; drawnGeomType = 'polygon'; } else { // removes unsupported Objects map.removeLayer(e.layer); return; } // Removes the drawn Layer, which will be re-added after Moderation Confirmation map.removeLayer(e.layer); // Checks if User is logged in if (!currentUser) { showLoginModal(); return; } // Populates hidden Fields and opens Create Modal document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); document.getElementById('create-geom-type').value = drawnGeomType; document.getElementById('create-modal').style.display = 'flex'; }); // ===================================================================== // Block 7: API Communication // ===================================================================== // Generic API Call Function function apiCall(data, callback) { const formData = new FormData(); for (const key in data) { formData.append(key, data[key]); } fetch(API_URL, { method: 'POST', body: formData }) .then(function (response) { return response.json().then(function (json) { json._status = response.status; return json; }); }) .then(function (json) { callback(json); }) .catch(function (error) { console.error('API Error:', error); Swal.fire('Verbindungsfehler', 'Verbindung zum Server fehlgeschlagen.', 'error'); }); } // Loads all Contributions from API and displays Contributions on Map function loadContributions() { const readParams = { action: 'read', municipality_id: MUNICIPALITY.id }; // Sends Browser ID for persistent Vote Display readParams.browser_id = browserId; apiCall(readParams, function (data) { if (data.error) { console.error('Load Error:', data.error); return; } contributionsData = data.features || []; // Restores Vote Highlights from API Response if (data.user_votes) { userVotes = {}; for (const key in data.user_votes) { userVotes[key] = data.user_votes[key]; } } // Removes existing Layer if present if (contributionsLayer) { map.removeLayer(contributionsLayer); layerControl.removeLayer(contributionsLayer); } // Creates new GeoJSON Layer contributionsLayer = L.geoJSON(data, { pointToLayer: stylePoint, style: styleLinePolygon, onEachFeature: bindFeaturePopup }).addTo(map); layerControl.addOverlay(contributionsLayer, ' Beiträge'); // Update Sidebar List and Statistics updateContributionsList(); updateStatistics(); }); } // ===================================================================== // Block 8: Feature Styling by Category // ===================================================================== // Style for Point Features (CircleMarkers) function stylePoint(feature, latlng) { const cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; return L.circleMarker(latlng, { radius: 8, color: cat.color, weight: 3, fillColor: cat.color, fillOpacity: 0.25, opacity: 0.8 }); } // Style for Line and Polygon Features function styleLinePolygon(feature) { const cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; return { color: cat.color, weight: 3, opacity: 0.8, fillColor: cat.color, fillOpacity: 0.25 }; } // ===================================================================== // Block 9: Feature Popups for Read, Edit, Delete and Vote // ===================================================================== // Builds Popup HTML for Features function buildPopupHtml(feature) { const props = feature.properties; const cat = CATEGORIES[props.category] || CATEGORIES.other; // Formats Date const date = new Date(props.created_at); const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); let html = '' + ''; return html; } // Binds Popup and Tooltip to Feature Layer function bindFeaturePopup(feature, layer) { const cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; // Dynamic Popup — rebuilt every Time the Popup opens layer.bindPopup(function () { return buildPopupHtml(feature); }, { maxWidth: 320, minWidth: 240 }); // Loads Comments when Popup opens layer.on('popupopen', function () { loadComments(feature.properties.contribution_id); }); // Tooltip on Hover layer.bindTooltip(categoryIcon(cat) + ' ' + escapeHtml(feature.properties.title), { direction: 'top', offset: [0, -10] }); } // ===================================================================== // Block 10: CRUD Operations // ===================================================================== // CREATE: Submits new Contributions from Modal function submitCreate() { const category = document.getElementById('create-category').value; const title = document.getElementById('create-title').value.trim(); const description = document.getElementById('create-description').value.trim(); const geom = document.getElementById('create-geom').value; const geomType = document.getElementById('create-geom-type').value; const photoInput = document.getElementById('create-photo'); // Validates required Fields if (!category) { Swal.fire('Kategorie fehlt', 'Bitte wählen Sie eine Kategorie aus.', 'warning'); return; } if (!title) { Swal.fire('Titel fehlt', 'Bitte geben Sie einen Titel ein.', 'warning'); return; } if (!geom) { Swal.fire('Geometrie fehlt', 'Bitte zeichnen Sie zuerst ein Objekt auf der Karte.', 'warning'); return; } // Builds FormData manually to include Photo File const formData = new FormData(); formData.append('action', 'create'); formData.append('municipality_id', MUNICIPALITY.id); formData.append('category', category); formData.append('title', title); formData.append('description', description); formData.append('geom', geom); formData.append('geom_type', geomType); formData.append('author_name', currentUser); formData.append('browser_id', browserId); // Appends Photo File if selected if (photoInput.files.length > 0) { formData.append('photo', photoInput.files[0]); } // Sends directly via fetch not through apiCall, because of File Upload fetch(API_URL, { method: 'POST', body: formData }) .then(function (response) { return response.json(); }) .then(function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } // Triggers Reverse Geocoding in Background if (response.contribution_id && drawnGeometry) { const coords = drawnGeomType === 'point' ? drawnGeometry.coordinates : drawnGeomType === 'line' ? drawnGeometry.coordinates[0] : drawnGeometry.coordinates[0][0]; reverseGeocode(response.contribution_id, coords[1], coords[0]); } Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung durch das Moderationsteam veröffentlicht.', 'success'); closeCreateModal(); loadContributions(); }) .catch(function (error) { console.error('Upload Error:', error); Swal.fire('Verbindungsfehler', 'Verbindung zum Server fehlgeschlagen.', 'error'); }); } // Cancels Create, closes Modal and clears Form function cancelCreate() { closeCreateModal(); } function closeCreateModal() { document.getElementById('create-modal').style.display = 'none'; document.getElementById('create-category').value = ''; document.getElementById('create-title').value = ''; document.getElementById('create-description').value = ''; document.getElementById('create-geom').value = ''; document.getElementById('create-geom-type').value = ''; // Resets Photo Upload document.getElementById('create-photo').value = ''; document.getElementById('photo-preview').style.display = 'none'; drawnGeometry = null; drawnGeomType = null; } // UPDATE: Edits existing Contributions function editContribution(contributionId) { // Finds Contribution in local Data const contribution = contributionsData.find(function (f) { return f.properties.contribution_id === contributionId; }); if (!contribution) return; const props = contribution.properties; Swal.fire({ title: 'Beitrag bearbeiten', html: '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
', showCancelButton: true, confirmButtonText: 'Speichern', cancelButtonText: 'Abbrechen', confirmButtonColor: MUNICIPALITY.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 }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } Swal.fire('Gespeichert!', 'Der Beitrag wurde aktualisiert.', 'success'); loadContributions(); }); }); } // DELETE: Deletes existing Contributions function deleteContribution(contributionId) { Swal.fire({ title: 'Beitrag löschen?', text: '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', contribution_id: contributionId }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } Swal.fire('Gelöscht!', 'Der Beitrag wurde entfernt.', 'success'); map.closePopup(); loadContributions(); }); }); } // VOTE: Like or Dislike existing Contributions function voteContribution(contributionId, voteType) { if (!currentUser) { showLoginModal(); return; } apiCall({ action: 'vote', contribution_id: contributionId, voter_name: currentUser, vote_type: voteType, browser_id: browserId }, function (response) { if (response.error) { return; } // Updates local Vote State const likeBtn = document.getElementById('vote-like-' + contributionId); const dislikeBtn = document.getElementById('vote-dislike-' + contributionId); const likesSpan = document.getElementById('likes-' + contributionId); const dislikesSpan = document.getElementById('dislikes-' + contributionId); // Finds Feature in Contributions to update Properties const feature = contributionsData.find(function (f) { return f.properties.contribution_id === contributionId; }); if (response.action === 'created') { userVotes[contributionId] = voteType; if (voteType === 'like') { likeBtn.classList.add('liked'); likesSpan.textContent = parseInt(likesSpan.textContent) + 1; if (feature) feature.properties.likes_count++; } else { dislikeBtn.classList.add('disliked'); dislikesSpan.textContent = parseInt(dislikesSpan.textContent) + 1; if (feature) feature.properties.dislikes_count++; } } else if (response.action === 'removed') { delete userVotes[contributionId]; if (voteType === 'like') { likeBtn.classList.remove('liked'); likesSpan.textContent = Math.max(0, parseInt(likesSpan.textContent) - 1); if (feature) feature.properties.likes_count = Math.max(0, feature.properties.likes_count - 1); } else { dislikeBtn.classList.remove('disliked'); dislikesSpan.textContent = Math.max(0, parseInt(dislikesSpan.textContent) - 1); if (feature) feature.properties.dislikes_count = Math.max(0, feature.properties.dislikes_count - 1); } } else if (response.action === 'changed') { userVotes[contributionId] = voteType; if (voteType === 'like') { likeBtn.classList.add('liked'); dislikeBtn.classList.remove('disliked'); likesSpan.textContent = parseInt(likesSpan.textContent) + 1; dislikesSpan.textContent = Math.max(0, parseInt(dislikesSpan.textContent) - 1); if (feature) { feature.properties.likes_count++; feature.properties.dislikes_count = Math.max(0, feature.properties.dislikes_count - 1); } } else { dislikeBtn.classList.add('disliked'); likeBtn.classList.remove('liked'); dislikesSpan.textContent = parseInt(dislikesSpan.textContent) + 1; likesSpan.textContent = Math.max(0, parseInt(likesSpan.textContent) - 1); if (feature) { feature.properties.dislikes_count++; feature.properties.likes_count = Math.max(0, feature.properties.likes_count - 1); } } } }); } // ===================================================================== // Block 11: Sidebar Contributions List // ===================================================================== function updateContributionsList() { const container = document.getElementById('contributions-list'); const searchInput = document.getElementById('list-search-input'); const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; // Filters by Categories and Search Term const filtered = contributionsData.filter(function (f) { const props = f.properties; const matchesCategory = activeFilters.indexOf(props.category) !== -1; const cat = CATEGORIES[props.category] || CATEGORIES.other; const matchesSearch = !searchTerm || props.title.toLowerCase().indexOf(searchTerm) !== -1 || (props.description && props.description.toLowerCase().indexOf(searchTerm) !== -1) || props.author_name.toLowerCase().indexOf(searchTerm) !== -1 || cat.label.toLowerCase().indexOf(searchTerm) !== -1; return matchesCategory && matchesSearch; }); // Sorts by Date (newest first) filtered.sort(function (a, b) { return new Date(b.properties.created_at) - new Date(a.properties.created_at); }); // Builds HTML if (filtered.length === 0) { container.innerHTML = '

Keine Beiträge gefunden.

'; return; } let html = ''; filtered.forEach(function (f) { const props = f.properties; const cat = CATEGORIES[props.category] || CATEGORIES.other; const date = new Date(props.created_at).toLocaleDateString('de-DE'); html += '' + '
' + '
' + '' + categoryIcon(cat) + ' ' + cat.label + '' + '
' + '
' + escapeHtml(props.title) + '
' + '
' + '' + escapeHtml(props.author_name) + ' · ' + date + '' + '' + ' ' + props.likes_count + '' + ' ' + props.dislikes_count + '' + '' + '
' + '
'; }); container.innerHTML = html; } // Flies to a Contribution on the Map and open Popup function flyToContribution(contributionId) { if (!contributionsLayer) return; contributionsLayer.eachLayer(function (layer) { if (layer.feature && layer.feature.properties.contribution_id === contributionId) { // Zooms to Feature if (layer.getLatLng) { // Point Feature map.flyTo(layer.getLatLng(), 17); } else if (layer.getBounds) { // Line or Polygon Feature map.flyToBounds(layer.getBounds(), { maxZoom: 17 }); } // Opens Popup layer.openPopup(); // Closes Sidebar on Mobile if (window.innerWidth < 769) { sidebar.close(); } } }); } // Search Input Event Listener document.getElementById('list-search-input').addEventListener('input', function () { updateContributionsList(); }); // ===================================================================== // Block 12: Sidebar Category Filter and Statistics // ===================================================================== // Builds Category Filter Checkboxes function buildCategoryFilter() { const container = document.getElementById('category-filter'); let html = ''; for (const key in CATEGORIES) { const cat = CATEGORIES[key]; const checked = activeFilters.indexOf(key) !== -1 ? 'checked' : ''; html += '' + ''; } container.innerHTML = html; } // Toggles a Category Filter on or off function toggleCategoryFilter(checkbox) { const category = checkbox.value; if (checkbox.checked) { if (activeFilters.indexOf(category) === -1) { activeFilters.push(category); } } else { activeFilters = activeFilters.filter(function (c) { return c !== category; }); } // Refilters Map Layer if (contributionsLayer) { contributionsLayer.eachLayer(function (layer) { if (layer.feature) { const cat = layer.feature.properties.category; if (activeFilters.indexOf(cat) !== -1) { const catDef = CATEGORIES[cat] || CATEGORIES.other; layer.setStyle({ color: catDef.color, weight: 3, opacity: 0.8, fillColor: catDef.color, fillOpacity: 0.25 }); if (layer.setRadius) layer.setRadius(8); layer.options.interactive = true; } else { layer.setStyle({ opacity: 0, fillOpacity: 0 }); if (layer.setRadius) layer.setRadius(0); layer.options.interactive = false; layer.closePopup(); layer.closeTooltip(); } } }); } // Updates List updateContributionsList(); } // Updates Statistics in Home Tab function updateStatistics() { const container = document.getElementById('stats-container'); const total = contributionsData.length; // Counts per Category const counts = {}; contributionsData.forEach(function (f) { const cat = f.properties.category; counts[cat] = (counts[cat] || 0) + 1; }); let html = '

' + total + ' Beiträge insgesamt

'; for (const key in CATEGORIES) { const cat = CATEGORIES[key]; const count = counts[key] || 0; if (count > 0) { html += '
' + categoryIcon(cat) + ' ' + cat.label + ': ' + count + '
'; } } container.innerHTML = html; } // ===================================================================== // Block 13: Modals — Welcome, Login, Info, Privacy, Imprint // ===================================================================== // Welcome Modal shows on new Visits function checkWelcomeModal() { const hasVisited = localStorage.getItem('webgis_welcomed'); if (!hasVisited) { document.getElementById('welcome-modal').style.display = 'flex'; } } function closeWelcomeAndShowLogin() { localStorage.setItem('webgis_welcomed', 'true'); document.getElementById('welcome-modal').style.display = 'none'; showLoginModal(); } // Login Modal shows new Session function showLoginModal() { document.getElementById('login-modal').style.display = 'flex'; document.getElementById('user-name-input').value = currentUser; document.getElementById('user-name-input').focus(); } function submitLogin() { const name = document.getElementById('user-name-input').value.trim(); if (!name) { Swal.fire('Name eingeben', 'Bitte geben Sie Ihren Namen ein.', 'warning'); return; } currentUser = name; sessionStorage.setItem('webgis_user', currentUser); document.getElementById('login-modal').style.display = 'none'; // Open Create Modal if Geometry is pending if (drawnGeometry) { document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); document.getElementById('create-geom-type').value = drawnGeomType; document.getElementById('create-modal').style.display = 'flex'; } } function skipLogin() { document.getElementById('login-modal').style.display = 'none'; } // Info Modal function showInfoModal() { Swal.fire({ title: 'Informationen', html: '

Das Bürgerbeteiligungsportal gestattet ' + 'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' + '' + MUNICIPALITY.name + ' mitzuwirken.

' + '

Bitte tragen Sie Hinweise, Anregungen und Vorschläge ' + 'mithilfe der Zeichenwerkzeuge auf der Karte ein.

', confirmButtonColor: MUNICIPALITY.primaryColor }); } // ===================================================================== // Block 14: Mobile Navigation // ===================================================================== function toggleMobileNav() { const nav = document.querySelector('.header-nav'); nav.classList.toggle('open'); } // Closes Mobile Nav when clicking outside document.addEventListener('click', function (e) { const nav = document.querySelector('.header-nav'); const toggle = document.querySelector('.header-menu-toggle'); if (nav.classList.contains('open') && !nav.contains(e.target) && !toggle.contains(e.target)) { nav.classList.remove('open'); } }); // Closes Modals on Escape Key document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { document.getElementById('welcome-modal').style.display = 'none'; document.getElementById('login-modal').style.display = 'none'; document.getElementById('create-modal').style.display = 'none'; } }); // ===================================================================== // Block 15: Utility Functions // ===================================================================== // Escapes HTML to prevent Cross-Site Scripting (XSS) in Popups and Lists function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } // Returns a colored Font Awesome Icon HTML String for a Category function categoryIcon(cat) { return ''; } // Reverse Geocodes Coordinates and saves Address to Contribution via API function reverseGeocode(contributionId, lat, lng) { fetch('https://nominatim.openstreetmap.org/reverse?format=json&lat=' + lat + '&lon=' + lng + '&zoom=18&addressdetails=1', { headers: { 'Accept-Language': 'de' } }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.display_name) { const addr = data.address || {}; const parts = []; if (addr.road) parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : '')); if (addr.city || addr.town || addr.village) parts.push(addr.city || addr.town || addr.village); const shortAddress = parts.length > 0 ? parts.join(', ') : data.display_name.split(',').slice(0, 2).join(','); // Saves Address to Database via API apiCall({ action: 'update', contribution_id: contributionId, address: shortAddress }, function () {}); } }) .catch(function () {}); } // Filters News Items in Sidebar by Search Term function filterNews() { const searchTerm = document.getElementById('news-search-input').value.toLowerCase(); const newsItems = document.querySelectorAll('#news-list .news-item'); newsItems.forEach(function (item) { const title = item.dataset.title || ''; const content = item.dataset.content || ''; const author = item.dataset.author || ''; // Shows Item if Search Term matches Title, Content or Author if (!searchTerm || title.indexOf(searchTerm) !== -1 || content.indexOf(searchTerm) !== -1 || author.indexOf(searchTerm) !== -1) { item.style.display = ''; } else { item.style.display = 'none'; } }); } // Loads and Displays Comments forContributions in Popups function loadComments(contributionId) { apiCall({ action: 'read_comments', contribution_id: contributionId }, function (response) { const listContainer = document.getElementById('comments-list-' + contributionId); const countSpan = document.getElementById('comment-count-' + contributionId); if (!listContainer) return; if (response.error || !response.comments || response.comments.length === 0) { listContainer.innerHTML = ''; if (countSpan) countSpan.textContent = '(0)'; return; } if (countSpan) countSpan.textContent = '(' + response.count + ')'; let html = ''; response.comments.forEach(function (comment) { const commentDate = new Date(comment.created_at).toLocaleDateString('de-DE'); const canDelete = comment.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN); html += ''; }); listContainer.innerHTML = html; }); } // Submits a new Comment on a Contribution function submitComment(contributionId) { const input = document.getElementById('comment-input-' + contributionId); const content = input ? input.value.trim() : ''; if (!content) return; apiCall({ action: 'create_comment', contribution_id: contributionId, author_name: currentUser, browser_id: browserId, content: content }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } // Clears Input and reloads Comments if (input) input.value = ''; loadComments(contributionId); }); } // Deletes a Comment function deleteComment(commentId, contributionId) { apiCall({ action: 'delete_comment', comment_id: commentId }, function (response) { if (response.error) return; // Reloads Comments after Deletion loadComments(contributionId); }); } // ===================================================================== // Block 16: Application Startup // ===================================================================== // Populates Category Dropdown in Create Modal from Categories Object function buildCategoryDropdown() { const select = document.getElementById('create-category'); for (const key in CATEGORIES) { const cat = CATEGORIES[key]; const option = document.createElement('option'); option.value = key; option.textContent = cat.label; option.dataset.icon = cat.faIcon; select.appendChild(option); } } // Populates Category Dropdown buildCategoryDropdown(); // Initializes Category Filter in Sidebar buildCategoryFilter(); // Loads Contributions from API loadContributions(); // Shows Welcome Modal on first Visit checkWelcomeModal(); // Photo Preview in Create Modal document.getElementById('create-photo').addEventListener('change', function () { const preview = document.getElementById('photo-preview'); const previewImg = document.getElementById('photo-preview-img'); if (this.files && this.files[0]) { const reader = new FileReader(); reader.onload = function (e) { previewImg.src = e.target.result; preview.style.display = 'block'; }; reader.readAsDataURL(this.files[0]); } else { preview.style.display = 'none'; } });