Files
webgis-lohne/public/js/app.js

1233 lines
45 KiB
JavaScript

// =====================================================================
// 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') ||
decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)webgis_user\s*=\s*([^;]*).*$)|^.*$/, '$1')) || '';
// 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 20
});
const basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://carto.com/">Carto</a>',
maxZoom: 20
});
const basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© <a href="https://www.esri.com/">Esri</a>',
maxZoom: 20
});
// Set Default Basemap
basemapCartoDB.addTo(map);
// Layer Control
const basemaps = {
'<i class="fa-solid fa-map" style="color:#404040;"></i> Hintergrundkarte (farbe)': basemapOSM,
'<i class="fa-solid fa-map" style="color:#404040;"></i> Hintergrundkarte (grau)': basemapCartoDB,
'<i class="fa-solid fa-satellite" style="color:#404040;"></i> 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 = '<i class="fa-solid fa-location-crosshairs"></i>';
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, '<i class="fa-solid fa-map-pin" style="color:#C00000;"></i> 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 called every Time the Popup opens
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 = '' +
'<div class="popup-detail">' +
'<span class="popup-detail-category">' + categoryIcon(cat) + ' ' + cat.label + '</span>' +
'<div class="popup-detail-title">' + escapeHtml(props.title) + '</div>' +
(props.description ? '<div class="popup-detail-description">' + escapeHtml(props.description) + '</div>' : '');
// Photo Toggle Button including hidden Photo
if (props.photo_path) {
html += '<div class="popup-photo-container" id="photo-container-' + props.contribution_id + '" style="display:none;">' +
'<img src="' + escapeHtml(props.photo_path) + '" alt="Foto" class="popup-photo-img" onclick="window.open(\'' + escapeHtml(props.photo_path) + '\', \'_blank\')">' +
'</div>';
}
// Meta Information
html += '<div class="popup-detail-meta">' +
'<i class="fa-solid fa-user"></i> ' + escapeHtml(props.author_name) +
' &middot; <i class="fa-solid fa-calendar"></i> ' + dateStr +
'</div>';
// Vote Buttons and Photo Toggle
html += '<div class="popup-detail-votes">' +
'<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'like' ? ' liked' : '') + '" id="vote-like-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'like\')" title="Gefällt mir">' +
'<i class="fa-solid fa-thumbs-up"></i> <span id="likes-' + props.contribution_id + '">' + props.likes_count + '</span>' +
'</button>' +
'<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'dislike' ? ' disliked' : '') + '" id="vote-dislike-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'dislike\')" title="Gefällt mir nicht">' +
'<i class="fa-solid fa-thumbs-down"></i> <span id="dislikes-' + props.contribution_id + '">' + props.dislikes_count + '</span>' +
'</button>';
// Photo Toggle Button
if (props.photo_path) {
html += '<button class="popup-vote-btn" onclick="togglePhoto(' + props.contribution_id + ')" title="Foto">' +
'<i class="fa-solid fa-camera"></i> <span id="photo-label-' + props.contribution_id + '">Foto anzeigen</span>' +
'</button>';
}
html += '</div>';
// Edit and Delete Buttons for Author or Admin
if (props.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN)) {
html += '<div class="popup-detail-actions">' +
'<button class="btn btn-primary" onclick="editContribution(' + props.contribution_id + ')"><i class="fa-solid fa-pen"></i> Bearbeiten</button>' +
'<button class="btn btn-danger" onclick="deleteContribution(' + props.contribution_id + ')"><i class="fa-solid fa-trash"></i> Löschen</button>' +
'</div>';
}
// Collapsible Comments Section
const commentCount = props.comment_count || 0;
html += '<div class="popup-comments">' +
'<div class="popup-comments-header" onclick="toggleComments(' + props.contribution_id + ')">' +
'<i class="fa-solid fa-comments"></i> Kommentare (' + commentCount + ')' +
' <i class="fa-solid fa-chevron-down popup-comments-toggle" id="comments-toggle-' + props.contribution_id + '"></i>' +
'</div>' +
'<div id="comments-section-' + props.contribution_id + '" style="display:none;">' +
'<div id="comments-list-' + props.contribution_id + '" class="popup-comments-list"></div>';
// Comment Input for logged-in Users
if (currentUser) {
html += '<div class="popup-comment-form">' +
'<input type="text" id="comment-input-' + props.contribution_id + '" class="popup-comment-input" placeholder="Kommentar schreiben..." maxlength="1000">' +
'<button class="popup-comment-submit" onclick="submitComment(' + props.contribution_id + ')" title="Senden">' +
'<i class="fa-solid fa-paper-plane"></i>' +
'</button>' +
'</div>';
}
html += '</div></div></div>';
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:
'<div style="text-align:left;">' +
'<div style="margin-bottom:12px;">' +
'<label style="display:block;font-weight:600;font-size:1.15rem;margin-bottom:4px;">Titel</label>' +
'<input id="swal-title" class="swal2-input" style="margin:0;width:100%;" value="' + escapeHtml(props.title) + '">' +
'</div>' +
'<div>' +
'<label style="display:block;font-weight:600;font-size:1.15rem;margin-bottom:4px;">Beschreibung</label>' +
'<textarea id="swal-description" class="swal2-textarea" style="margin:0;width:100%;">' + escapeHtml(props.description || '') + '</textarea>' +
'</div>' +
'</div>',
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 = '<p style="text-align:center;color:#999;padding:20px;">Keine Beiträge gefunden.</p>';
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 += '' +
'<div class="contribution-card" onclick="flyToContribution(' + props.contribution_id + ')">' +
'<div class="contribution-card-header">' +
'<span class="contribution-card-category">' + categoryIcon(cat) + ' ' + cat.label + '</span>' +
'</div>' +
'<div class="contribution-card-title">' + escapeHtml(props.title) + '</div>' +
'<div class="contribution-card-meta">' +
'<span>' + escapeHtml(props.author_name) + ' · ' + date + '</span>' +
'<span class="contribution-card-votes">' +
'<span title="Likes"><i class="fa-solid fa-thumbs-up"></i> ' + props.likes_count + '</span>' +
'<span title="Dislikes"><i class="fa-solid fa-thumbs-down"></i> ' + props.dislikes_count + '</span>' +
'<span title="Kommentare"><i class="fa-solid fa-comment"></i> ' + (props.comment_count || 0) + '</span>' +
'</span>' +
'</div>' +
'</div>';
});
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 += '' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;">' +
'<input type="checkbox" value="' + key + '" ' + checked + ' onchange="toggleCategoryFilter(this)">' +
'<span>' + categoryIcon(cat) + ' ' + cat.label + '</span>' +
'</label>';
}
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 = '<p style="font-size:0.9rem;"><strong>' + total + '</strong> Beiträge insgesamt</p>';
for (const key in CATEGORIES) {
const cat = CATEGORIES[key];
const count = counts[key] || 0;
if (count > 0) {
html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.85rem;">' +
categoryIcon(cat) + ' ' +
cat.label + ': ' + count +
'</div>';
}
}
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.cookie = 'webgis_user=' + encodeURIComponent(name) + ';path=/;max-age=31536000;SameSite=Lax';
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: '<p style="text-align:left;line-height:1.6;">Das Bürgerbeteiligungsportal gestattet ' +
'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' +
'<strong>' + MUNICIPALITY.name + '</strong> mitzuwirken.</p>' +
'<p style="text-align:left;line-height:1.6;">Bitte tragen Sie Hinweise, Anregungen und Vorschläge ' +
'mithilfe der Zeichenwerkzeuge auf der Karte ein.</p>',
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 '<i class="fa-solid ' + cat.faIcon + '" style="color:' + cat.color + ';"></i>';
}
// 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 = '<div class="popup-comment-empty">Noch keine Kommentare.</div>';
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 += '<div class="popup-comment">' +
'<div class="popup-comment-meta">' +
'<strong>' + escapeHtml(comment.author_name) + '</strong>' +
' · ' + commentDate +
(canDelete ? ' · <a href="#" onclick="deleteComment(' + comment.comment_id + ', ' + contributionId + ');return false;" class="popup-comment-delete"><i class="fa-solid fa-trash"></i></a>' : '') +
'</div>' +
'<div class="popup-comment-text">' + escapeHtml(comment.content) + '</div>' +
'</div>';
});
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);
});
}
// Toggles Photo Visibility in Popup
function togglePhoto(contributionId) {
const container = document.getElementById('photo-container-' + contributionId);
const label = document.getElementById('photo-label-' + contributionId);
if (!container) return;
if (container.style.display === 'none') {
container.style.display = 'block';
label.textContent = 'Foto verbergen';
} else {
container.style.display = 'none';
label.textContent = 'Foto anzeigen';
}
}
// Toggles Comments Section Visibility in Popup
function toggleComments(contributionId) {
const section = document.getElementById('comments-section-' + contributionId);
const toggle = document.getElementById('comments-toggle-' + contributionId);
if (!section) return;
if (section.style.display === 'none') {
section.style.display = 'block';
toggle.classList.remove('fa-chevron-down');
toggle.classList.add('fa-chevron-up');
// Loads Comments
loadComments(contributionId);
} else {
section.style.display = 'none';
toggle.classList.remove('fa-chevron-up');
toggle.classList.add('fa-chevron-down');
}
}
// =====================================================================
// 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';
}
});