1227 lines
45 KiB
JavaScript
1227 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 Citizen Portal
|
|
// =====================================================================
|
|
|
|
|
|
// =====================================================================
|
|
// 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();
|
|
buildCategoryFilter();
|
|
});
|
|
}
|
|
|
|
|
|
// =====================================================================
|
|
// 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) +
|
|
' · <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 class="empty-state">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 with Counts
|
|
function buildCategoryFilter() {
|
|
const container = document.getElementById('category-filter');
|
|
|
|
const counts = {};
|
|
contributionsData.forEach(function (f) {
|
|
const cat = f.properties.category;
|
|
counts[cat] = (counts[cat] || 0) + 1;
|
|
});
|
|
|
|
const total = contributionsData.length;
|
|
let html = '';
|
|
|
|
for (const key in CATEGORIES) {
|
|
const cat = CATEGORIES[key];
|
|
const checked = activeFilters.indexOf(key) !== -1 ? 'checked' : '';
|
|
const count = counts[key] || 0;
|
|
|
|
html += '<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;font-size:0.85rem;color:var(--color-text-secondary)">' +
|
|
'<input type="checkbox" value="' + key + '" ' + checked + ' onchange="toggleCategoryFilter(this)">' +
|
|
categoryIcon(cat) +
|
|
'<span>' + cat.label + ' (' + count + ')</span>' +
|
|
'</label>';
|
|
}
|
|
|
|
html += '<p style="margin-top:10px;font-size:0.85rem;color:var(--color-text-secondary)"><strong>' + total + '</strong> Beiträge insgesamt</p>';
|
|
|
|
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();
|
|
}
|
|
|
|
|
|
// =====================================================================
|
|
// 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>',
|
|
showDenyButton: true,
|
|
confirmButtonText: 'Schließen',
|
|
denyButtonText: '<i class="fa-solid fa-route"></i> Tutorial starten',
|
|
confirmButtonColor: MUNICIPALITY.primaryColor,
|
|
denyButtonColor: '#546E7A'
|
|
}).then(function (result) {
|
|
if (result.isDenied && typeof restartOnboarding === 'function') {
|
|
restartOnboarding();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// =====================================================================
|
|
// 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 + ' fa-fw" 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;
|
|
}
|
|
if (input) input.value = '';
|
|
Swal.fire({
|
|
title: 'Eingereicht!',
|
|
text: 'Ihr Kommentar wurde erfolgreich eingereicht und wird nach Prüfung durch das Moderationsteam veröffentlicht.',
|
|
icon: 'success',
|
|
timer: 3000,
|
|
showConfirmButton: true
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
}); |