addes app.js with map initialization, CRUD workflow, sidebar and modal logic
This commit is contained in:
890
public/js/app.js
Normal file
890
public/js/app.js
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
// =====================================================================
|
||||||
|
// WebGIS Citizen Participation Portal — Application Logic
|
||||||
|
// Initializes the Leaflet Map, loads Contributions from the API,
|
||||||
|
// handles the CRUD Workflow, and manages all UI Interactions.
|
||||||
|
//
|
||||||
|
// Depends on: MUNICIPALITY Object (set in index.php), Leaflet, Geoman,
|
||||||
|
// Sidebar v2, Geocoder, PolylineMeasure, Fullscreen, SweetAlert2
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 1: Configuration and Application State
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// API Endpoint — relative Path from public/ to api/
|
||||||
|
var API_URL = '../api/contributions.php';
|
||||||
|
|
||||||
|
// Current User Name — set via Login Modal, stored in sessionStorage
|
||||||
|
var currentUser = sessionStorage.getItem('webgis_user') || '';
|
||||||
|
|
||||||
|
// Category Definitions with Labels, Icons, and Colors
|
||||||
|
var CATEGORIES = {
|
||||||
|
mobility: { label: 'Mobilität', icon: '🚲', color: '#1565C0', faIcon: 'fa-bicycle' },
|
||||||
|
building: { label: 'Bauen', icon: '🏗️', color: '#E65100', faIcon: 'fa-helmet-safety' },
|
||||||
|
energy: { label: 'Energie', icon: '⚡', color: '#F9A825', faIcon: 'fa-bolt' },
|
||||||
|
environment: { label: 'Umwelt', icon: '🌳', color: '#2E7D32', faIcon: 'fa-tree' },
|
||||||
|
industry: { label: 'Industrie', icon: '🏭', color: '#6A1B9A', faIcon: 'fa-industry' },
|
||||||
|
consumption: { label: 'Konsum', icon: '🛒', color: '#AD1457', faIcon: 'fa-cart-shopping' },
|
||||||
|
other: { label: 'Sonstiges', icon: '📌', color: '#546E7A', faIcon: 'fa-map-pin' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Application State
|
||||||
|
var map; // Leaflet Map Instance
|
||||||
|
var sidebar; // Sidebar Instance
|
||||||
|
var contributionsLayer; // GeoJSON Layer holding all Contributions
|
||||||
|
var contributionsData = []; // Raw Contribution Data Array
|
||||||
|
var activeFilters = Object.keys(CATEGORIES); // Active Category Filters (all enabled by Default)
|
||||||
|
var drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman
|
||||||
|
var drawnGeomType = null; // Temporary Storage for Geometry Type
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 2: Map Initialization
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
map = L.map('map', {
|
||||||
|
center: MUNICIPALITY.center,
|
||||||
|
zoom: MUNICIPALITY.zoom,
|
||||||
|
zoomControl: false, // Added manually in Block 3 for Position Control
|
||||||
|
attributionControl: true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 3: Basemaps and Layer Control
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Basemap Tile Layers
|
||||||
|
var basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
var 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
|
||||||
|
});
|
||||||
|
|
||||||
|
var 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
|
||||||
|
});
|
||||||
|
|
||||||
|
var basemapTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a>',
|
||||||
|
maxZoom: 18
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set Default Basemap
|
||||||
|
basemapCartoDB.addTo(map);
|
||||||
|
|
||||||
|
// Layer Control
|
||||||
|
var basemaps = {
|
||||||
|
'OpenStreetMap': basemapOSM,
|
||||||
|
'CartoDB (hell)': basemapCartoDB,
|
||||||
|
'Satellit (Esri)': basemapSatellite,
|
||||||
|
'Topographisch': basemapTopo
|
||||||
|
};
|
||||||
|
|
||||||
|
var overlays = {}; // Populated later with Contribution Layer
|
||||||
|
|
||||||
|
var layerControl = L.control.layers(basemaps, overlays, {
|
||||||
|
position: 'topright',
|
||||||
|
collapsed: true
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 4: Map Controls
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Zoom Control (top right)
|
||||||
|
L.control.zoom({
|
||||||
|
position: 'topright'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Scale Bar (bottom right)
|
||||||
|
L.control.scale({
|
||||||
|
position: 'bottomright',
|
||||||
|
maxWidth: 200,
|
||||||
|
imperial: false
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Fullscreen Button
|
||||||
|
L.control.fullscreen({
|
||||||
|
position: 'topright',
|
||||||
|
title: 'Vollbild',
|
||||||
|
titleCancel: 'Vollbild beenden'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Address Search (Geocoder with Nominatim)
|
||||||
|
L.Control.geocoder({
|
||||||
|
position: 'topright',
|
||||||
|
placeholder: 'Adresse suchen...',
|
||||||
|
defaultMarkGeocode: true,
|
||||||
|
geocoder: L.Control.Geocoder.nominatim({
|
||||||
|
geocodingQueryParams: {
|
||||||
|
countrycodes: 'de',
|
||||||
|
viewbox: '8.0,52.5,8.5,52.8',
|
||||||
|
bounded: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Polyline Measure Tool
|
||||||
|
L.control.polylineMeasure({
|
||||||
|
position: 'topright',
|
||||||
|
unit: 'metres',
|
||||||
|
showBearings: false,
|
||||||
|
clearMeasurementsOnStop: false,
|
||||||
|
showClearControl: true
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Mouse Position Display
|
||||||
|
var MousePositionControl = L.Control.extend({
|
||||||
|
options: { position: 'bottomright' },
|
||||||
|
|
||||||
|
onAdd: function () {
|
||||||
|
var container = L.DomUtil.create('div', 'mouse-position-display');
|
||||||
|
container.style.background = 'rgba(255,255,255,0.85)';
|
||||||
|
container.style.padding = '2px 8px';
|
||||||
|
container.style.fontSize = '12px';
|
||||||
|
container.style.borderRadius = '4px';
|
||||||
|
container.style.fontFamily = 'monospace';
|
||||||
|
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
|
||||||
|
var GpsControl = L.Control.extend({
|
||||||
|
options: { position: 'topright' },
|
||||||
|
|
||||||
|
onAdd: function () {
|
||||||
|
var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
|
||||||
|
var button = L.DomUtil.create('a', '', container);
|
||||||
|
button.href = '#';
|
||||||
|
button.title = 'Mein Standort';
|
||||||
|
button.innerHTML = '<i class="fa-solid fa-location-crosshairs"></i>';
|
||||||
|
button.style.fontSize = '16px';
|
||||||
|
button.style.display = 'flex';
|
||||||
|
button.style.alignItems = 'center';
|
||||||
|
button.style.justifyContent = 'center';
|
||||||
|
button.style.width = '30px';
|
||||||
|
button.style.height = '30px';
|
||||||
|
|
||||||
|
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
|
||||||
|
var 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 erlauben 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
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a Shape is drawn, capture the Geometry and open the Create Modal
|
||||||
|
map.on('pm:create', function (e) {
|
||||||
|
var geojson = e.layer.toGeoJSON().geometry;
|
||||||
|
|
||||||
|
// Determine Geometry Type and normalize 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 {
|
||||||
|
// Unsupported Shape — remove from Map and exit
|
||||||
|
map.removeLayer(e.layer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the drawn Layer — it will be re-added after API Confirmation
|
||||||
|
map.removeLayer(e.layer);
|
||||||
|
|
||||||
|
// Check if User is logged in
|
||||||
|
if (!currentUser) {
|
||||||
|
Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um Beiträge zu erstellen.', 'info');
|
||||||
|
showLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate hidden Fields and open 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) {
|
||||||
|
var formData = new FormData();
|
||||||
|
for (var 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', 'Die Verbindung zum Server ist fehlgeschlagen.', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all Contributions from API and display on Map
|
||||||
|
function loadContributions() {
|
||||||
|
apiCall({ action: 'read', municipality_id: MUNICIPALITY.id }, function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Load Error:', data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contributionsData = data.features || [];
|
||||||
|
|
||||||
|
// Remove existing Layer if present
|
||||||
|
if (contributionsLayer) {
|
||||||
|
map.removeLayer(contributionsLayer);
|
||||||
|
layerControl.removeLayer(contributionsLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 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) {
|
||||||
|
var cat = CATEGORIES[feature.properties.category] || CATEGORIES.other;
|
||||||
|
|
||||||
|
return L.circleMarker(latlng, {
|
||||||
|
radius: 8,
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: cat.color,
|
||||||
|
fillOpacity: 0.9
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style for Line and Polygon Features
|
||||||
|
function styleLinePolygon(feature) {
|
||||||
|
var 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 (Read, Vote, Edit, Delete)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function bindFeaturePopup(feature, layer) {
|
||||||
|
var props = feature.properties;
|
||||||
|
var cat = CATEGORIES[props.category] || CATEGORIES.other;
|
||||||
|
|
||||||
|
// Format Date
|
||||||
|
var date = new Date(props.created_at);
|
||||||
|
var dateStr = date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build Popup HTML
|
||||||
|
var html = '' +
|
||||||
|
'<div class="popup-detail">' +
|
||||||
|
'<span class="popup-detail-category">' + cat.icon + ' ' + cat.label + '</span>' +
|
||||||
|
'<div class="popup-detail-title">' + escapeHtml(props.title) + '</div>' +
|
||||||
|
(props.description ? '<div class="popup-detail-description">' + escapeHtml(props.description) + '</div>' : '') +
|
||||||
|
'<div class="popup-detail-meta">' +
|
||||||
|
'<i class="fa-solid fa-user"></i> ' + escapeHtml(props.author_name) +
|
||||||
|
' · <i class="fa-solid fa-calendar"></i> ' + dateStr +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="popup-detail-votes">' +
|
||||||
|
'<button class="popup-vote-btn" 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" 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>' +
|
||||||
|
'</div>' +
|
||||||
|
'<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>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
layer.bindPopup(html, { maxWidth: 320, minWidth: 240 });
|
||||||
|
|
||||||
|
// Tooltip on Hover
|
||||||
|
layer.bindTooltip(cat.icon + ' ' + escapeHtml(props.title), {
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -10]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 10: CRUD Operations
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// CREATE — Submit new Contribution from Modal
|
||||||
|
function submitCreate() {
|
||||||
|
var category = document.getElementById('create-category').value;
|
||||||
|
var title = document.getElementById('create-title').value.trim();
|
||||||
|
var description = document.getElementById('create-description').value.trim();
|
||||||
|
var geom = document.getElementById('create-geom').value;
|
||||||
|
var geomType = document.getElementById('create-geom-type').value;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiCall({
|
||||||
|
action: 'create',
|
||||||
|
municipality_id: MUNICIPALITY.id,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
geom: geom,
|
||||||
|
geom_type: geomType,
|
||||||
|
author_name: currentUser
|
||||||
|
}, function (response) {
|
||||||
|
if (response.error) {
|
||||||
|
Swal.fire('Fehler', response.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung veröffentlicht.', 'success');
|
||||||
|
closeCreateModal();
|
||||||
|
loadContributions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel Create — close Modal and clear 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 = '';
|
||||||
|
drawnGeometry = null;
|
||||||
|
drawnGeomType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE — Edit an existing Contribution
|
||||||
|
function editContribution(contributionId) {
|
||||||
|
// Find Contribution in local Data
|
||||||
|
var contribution = contributionsData.find(function (f) {
|
||||||
|
return f.properties.contribution_id === contributionId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contribution) return;
|
||||||
|
|
||||||
|
var props = contribution.properties;
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Beitrag bearbeiten',
|
||||||
|
html:
|
||||||
|
'<div style="text-align:left;">' +
|
||||||
|
'<label style="font-weight:600;font-size:0.85rem;">Titel</label>' +
|
||||||
|
'<input id="swal-title" class="swal2-input" value="' + escapeHtml(props.title) + '">' +
|
||||||
|
'<label style="font-weight:600;font-size:0.85rem;">Beschreibung</label>' +
|
||||||
|
'<textarea id="swal-description" class="swal2-textarea">' + escapeHtml(props.description || '') + '</textarea>' +
|
||||||
|
'</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 — Delete a Contribution
|
||||||
|
function deleteContribution(contributionId) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Beitrag 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',
|
||||||
|
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 a Contribution
|
||||||
|
function voteContribution(contributionId, voteType) {
|
||||||
|
if (!currentUser) {
|
||||||
|
Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um abzustimmen.', 'info');
|
||||||
|
showLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiCall({
|
||||||
|
action: 'vote',
|
||||||
|
contribution_id: contributionId,
|
||||||
|
voter_name: currentUser,
|
||||||
|
vote_type: voteType
|
||||||
|
}, function (response) {
|
||||||
|
if (response.error) {
|
||||||
|
Swal.fire('Hinweis', response.error, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Vote Counts in the Popup without reloading everything
|
||||||
|
loadContributions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 11: Sidebar — Contributions List
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function updateContributionsList() {
|
||||||
|
var container = document.getElementById('contributions-list');
|
||||||
|
var searchInput = document.getElementById('list-search-input');
|
||||||
|
var searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
||||||
|
|
||||||
|
// Filter by active Categories and Search Term
|
||||||
|
var filtered = contributionsData.filter(function (f) {
|
||||||
|
var props = f.properties;
|
||||||
|
var matchesCategory = activeFilters.indexOf(props.category) !== -1;
|
||||||
|
var matchesSearch = !searchTerm ||
|
||||||
|
props.title.toLowerCase().indexOf(searchTerm) !== -1 ||
|
||||||
|
(props.description && props.description.toLowerCase().indexOf(searchTerm) !== -1) ||
|
||||||
|
props.author_name.toLowerCase().indexOf(searchTerm) !== -1;
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by Date (newest first)
|
||||||
|
filtered.sort(function (a, b) {
|
||||||
|
return new Date(b.properties.created_at) - new Date(a.properties.created_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build HTML
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<p style="text-align:center;color:#999;padding:20px;">Keine Beiträge gefunden.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
filtered.forEach(function (f) {
|
||||||
|
var props = f.properties;
|
||||||
|
var cat = CATEGORIES[props.category] || CATEGORIES.other;
|
||||||
|
var 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">' + cat.icon + ' ' + 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>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fly to a Contribution on the Map and open its Popup
|
||||||
|
function flyToContribution(contributionId) {
|
||||||
|
if (!contributionsLayer) return;
|
||||||
|
|
||||||
|
contributionsLayer.eachLayer(function (layer) {
|
||||||
|
if (layer.feature && layer.feature.properties.contribution_id === contributionId) {
|
||||||
|
// Zoom 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 });
|
||||||
|
}
|
||||||
|
// Open Popup
|
||||||
|
layer.openPopup();
|
||||||
|
// Close 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
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Build Category Filter Checkboxes
|
||||||
|
function buildCategoryFilter() {
|
||||||
|
var container = document.getElementById('category-filter');
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
for (var key in CATEGORIES) {
|
||||||
|
var cat = CATEGORIES[key];
|
||||||
|
var 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 style="display:inline-block;width:12px;height:12px;border-radius:50%;background:' + cat.color + ';"></span>' +
|
||||||
|
'<span>' + cat.icon + ' ' + cat.label + '</span>' +
|
||||||
|
'</label>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle a Category Filter on/off
|
||||||
|
function toggleCategoryFilter(checkbox) {
|
||||||
|
var category = checkbox.value;
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (activeFilters.indexOf(category) === -1) {
|
||||||
|
activeFilters.push(category);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeFilters = activeFilters.filter(function (c) { return c !== category; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-filter the Map Layer
|
||||||
|
if (contributionsLayer) {
|
||||||
|
contributionsLayer.eachLayer(function (layer) {
|
||||||
|
if (layer.feature) {
|
||||||
|
var cat = layer.feature.properties.category;
|
||||||
|
if (activeFilters.indexOf(cat) !== -1) {
|
||||||
|
layer.setStyle({ opacity: 1, fillOpacity: layer.feature.geometry.type === 'Point' ? 0.9 : 0.25 });
|
||||||
|
if (layer.setRadius) layer.setRadius(8);
|
||||||
|
} else {
|
||||||
|
layer.setStyle({ opacity: 0, fillOpacity: 0 });
|
||||||
|
if (layer.setRadius) layer.setRadius(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update List
|
||||||
|
updateContributionsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Statistics in Home Tab
|
||||||
|
function updateStatistics() {
|
||||||
|
var container = document.getElementById('stats-container');
|
||||||
|
var total = contributionsData.length;
|
||||||
|
|
||||||
|
// Count per Category
|
||||||
|
var counts = {};
|
||||||
|
contributionsData.forEach(function (f) {
|
||||||
|
var cat = f.properties.category;
|
||||||
|
counts[cat] = (counts[cat] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '<p style="font-size:0.9rem;"><strong>' + total + '</strong> Beiträge insgesamt</p>';
|
||||||
|
|
||||||
|
for (var key in CATEGORIES) {
|
||||||
|
var cat = CATEGORIES[key];
|
||||||
|
var count = counts[key] || 0;
|
||||||
|
if (count > 0) {
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.85rem;">' +
|
||||||
|
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + cat.color + ';"></span>' +
|
||||||
|
cat.label + ': ' + count +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 13: Modals — Welcome, Login, Info, Privacy, Imprint
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Welcome Modal — show on first Visit
|
||||||
|
function checkWelcomeModal() {
|
||||||
|
var 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
|
||||||
|
function showLoginModal() {
|
||||||
|
document.getElementById('login-modal').style.display = 'flex';
|
||||||
|
document.getElementById('user-name-input').value = currentUser;
|
||||||
|
document.getElementById('user-name-input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitLogin() {
|
||||||
|
var 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipLogin() {
|
||||||
|
document.getElementById('login-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Modal
|
||||||
|
function showInfoModal() {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Über das Portal',
|
||||||
|
html: '<p style="text-align:left;line-height:1.6;">Das Bürgerbeteiligungsportal ermöglicht es ' +
|
||||||
|
'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;">Tragen Sie Hinweise, Ideen und Verbesserungsvorschläge ' +
|
||||||
|
'direkt auf der Karte ein.</p>',
|
||||||
|
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privacy Modal
|
||||||
|
function showPrivacyModal() {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Datenschutz',
|
||||||
|
html: '<p style="text-align:left;line-height:1.6;">Dieses Portal speichert die von Ihnen ' +
|
||||||
|
'eingegebenen Daten (Name, Beiträge, Bewertungen) zur Durchführung der Bürgerbeteiligung.</p>' +
|
||||||
|
'<p style="text-align:left;line-height:1.6;">Ihre Daten werden nicht an Dritte weitergegeben. ' +
|
||||||
|
'Details entnehmen Sie bitte der vollständigen Datenschutzerklärung der Stadt ' +
|
||||||
|
MUNICIPALITY.name + '.</p>',
|
||||||
|
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imprint Modal
|
||||||
|
function showImprintModal() {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Impressum',
|
||||||
|
html: '<p style="text-align:left;line-height:1.6;">Stadt ' + MUNICIPALITY.name + '</p>' +
|
||||||
|
'<p style="text-align:left;line-height:1.6;color:#777;">Die vollständigen Angaben gemäß § 5 TMG ' +
|
||||||
|
'werden hier ergänzt, sobald das Portal in den Produktivbetrieb geht.</p>',
|
||||||
|
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 14: Mobile Navigation
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
function toggleMobileNav() {
|
||||||
|
var nav = document.querySelector('.header-nav');
|
||||||
|
nav.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close Mobile Nav when clicking outside
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var nav = document.querySelector('.header-nav');
|
||||||
|
var toggle = document.querySelector('.header-menu-toggle');
|
||||||
|
|
||||||
|
if (nav.classList.contains('open') && !nav.contains(e.target) && !toggle.contains(e.target)) {
|
||||||
|
nav.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close 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
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS in Popups and Lists
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(text));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Block 16: Application Startup
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Initialize Category Filter in Sidebar
|
||||||
|
buildCategoryFilter();
|
||||||
|
|
||||||
|
// Load Contributions from API
|
||||||
|
loadContributions();
|
||||||
|
|
||||||
|
// Show Welcome Modal on first Visit
|
||||||
|
checkWelcomeModal();
|
||||||
Reference in New Issue
Block a user