30 Commits

Author SHA1 Message Date
855b69f95d removed obsolete local plugins, now included with CDN 2026-04-17 20:52:09 +02:00
77df35926d addes app.js with map initialization, CRUD workflow, sidebar and modal logic 2026-04-17 20:45:03 +02:00
65ef7f07c9 rebuild styles.css with mobile-first layout and municipality theming 2026-04-17 20:35:37 +02:00
6eca88e941 rebuild index.php with header, map, sidebar, footer and modals 2026-04-17 20:33:56 +02:00
801131985d commented action handlers 2026-04-17 20:15:05 +02:00
4707e73421 commented READ action handler 2026-04-17 19:59:23 +02:00
241ec75323 added contributions API endpoint with CRUD and voting with prepared statements 2026-04-17 19:32:50 +02:00
d3297d2a3c added comments to db 2026-04-17 19:29:56 +02:00
c7e9444903 added comments to init 2026-04-17 18:37:33 +02:00
72315b4030 added database helper including JSON response and input validation utilities 2026-04-17 16:36:16 +02:00
403d81b132 added database helper including JSON response and input validation utilities 2026-04-17 16:24:39 +02:00
4f35ddeafe added .gitattributes to specify line feed line endings for .sh and .sql files 2026-04-17 15:49:21 +02:00
19b038d4f5 improve backup script preflight checks and file size validation 2026-04-17 15:42:02 +02:00
4554ea3ff0 added votes index and documented future migration tasks 2026-04-17 15:22:15 +02:00
0083a05482 added votes index and documented future migration tasks 2026-04-17 15:17:45 +02:00
041d1603dc shortened .env.example 2026-04-16 17:16:44 +02:00
b3a4ba6d4a added database backup script with daily, weekly and monthly rotation 2026-04-16 17:12:46 +02:00
04dc118598 added initial database schema migration 2026-04-16 16:44:24 +02:00
dec36d4053 fixed .env path in init.php 2026-04-16 16:14:43 +02:00
d2f2b577be added README.md 2026-04-16 16:12:57 +02:00
a640ed1b78 commented example .env 2026-04-16 16:08:34 +02:00
7c0c0b5048 added example env. 2026-04-16 16:07:38 +02:00
50035a524d created project structureapi /, public/, migrations/, scripts/, legacy/ 2026-04-16 16:00:35 +02:00
e8ce6c6f36 init adapted to server 2026-04-16 15:23:17 +02:00
97ab6a52ab commented init.php 2026-04-15 15:28:28 +02:00
b8f1c32a22 init.php connection to db with ssh 2026-04-15 14:56:44 +02:00
0aeee9a168 gitignore added 2026-04-15 14:42:07 +02:00
luptmoor
1f8e3935bb hostname as var 2026-04-15 16:23:18 +02:00
luptmoor
7bcb31a8f8 user and pw as env vars 2026-04-15 14:26:27 +02:00
luptmoor
a8ab95ff3a course example 2026-04-15 14:23:12 +02:00
21 changed files with 3148 additions and 57 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Example Environment Configfile
POSTGRES_HOST=postgres_host
POSTGRES_PORT=postgres_port
POSTGRES_DB=postgres_database
POSTGRES_USER=postgres_user
POSTGRES_PASSWORD=

8
.gitattributes vendored Normal file
View File

@@ -0,0 +1,8 @@
# Specifies Line Feed (LF) Line Endings for Shell Scripts
*.sh text eol=lf
# # Specifies Line Feed (LF) Line Endings for SQL Files
*.sql text eol=lf
# Letd Git decide for other Files
* text=auto

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.vscode/
*.log

326
api/contributions.php Normal file
View File

@@ -0,0 +1,326 @@
<?php
// =====================================================================
// Contributions API Endpoint
// Handles CRUD Operations for Contributions (Points, Lines, Polygons)
// and Voting. Actions are determined by the 'action' Parameter in
// the Request.
//
// Supported Actions:
// read — Load approved Contributions
// create — Insert Contributions
// update — Update Contributions
// delete — Delete Contributions
// vote — Like or Dislike Contributions
// =====================================================================
require_once __DIR__ . '/db.php';
// ---------------------------------------------------------------------
// Read Action Parameter and Route to correct Handler
// ---------------------------------------------------------------------
$input = get_input();
$action = $input['action'] ?? '';
switch ($action) {
case 'read':
handle_read($input);
break;
case 'create':
handle_create($input);
break;
case 'update':
handle_update($input);
break;
case 'delete':
handle_delete($input);
break;
case 'vote':
handle_vote($input);
break;
default:
error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.');
}
// =====================================================================
// Action Handlers
// =====================================================================
// ---------------------------------------------------------------------
// READ: Loads approved Contributions as GeoJSON FeatureCollection
// Required: municipality_id
// Optional: category
// ---------------------------------------------------------------------
function handle_read($input) {
$pdo = get_db();
// Validate Input
$missing = validate_required($input, ['municipality_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$municipality_id = $input['municipality_id'];
// Builds SQL Query with Placeholders for prepared Statement
$sql = "SELECT *, ST_AsGeoJSON(geom) AS geojson
FROM contributions
WHERE municipality_id = :mid AND status = 'approved'";
$params = [':mid' => $municipality_id];
// Optional: Filters by Category
if (!empty($input['category'])) {
$sql .= " AND category = :cat";
$params[':cat'] = $input['category'];
}
$sql .= " ORDER BY created_at DESC";
try {
// Prepared Statement to prevent SQL Injection
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
// Fetches Results as PHP-Array
$rows = $stmt->fetchAll();
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
// Builds GeoJSON FeatureCollection
$features = [];
foreach ($rows as $row) {
$geometry = json_decode($row['geojson']);
// Removes raw Geometry Columns from Properties
unset($row['geom']);
unset($row['geojson']);
$features[] = [
'type' => 'Feature',
'geometry' => $geometry,
'properties' => $row
];
}
$featureCollection = [
'type' => 'FeatureCollection',
'features' => $features
];
json_response($featureCollection);
}
// ---------------------------------------------------------------------
// CREATE: Inserts new Contributions
// Required: municipality_id, geom, geom_type, category, title, author_name
// Optional: description
// ---------------------------------------------------------------------
function handle_create($input) {
$pdo = get_db();
// Validates Input
$missing = validate_required($input, [
'municipality_id', 'geom', 'geom_type', 'category', 'title', 'author_name'
]);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
// Validates Geometry Type
$valid_geom_types = ['point', 'line', 'polygon'];
if (!in_array($input['geom_type'], $valid_geom_types)) {
error_response('Invalid Geometry Type. Must be: ' . implode(', ', $valid_geom_types));
}
// Validates GeoJSON
$geojson = json_decode($input['geom']);
if (!$geojson || !isset($geojson->type)) {
error_response('Invalid GeoJSON in Geometry Field.');
}
// Prepared SQL Statement
try {
$stmt = $pdo->prepare("
INSERT INTO contributions
(municipality_id, geom, geom_type, category, title, description, author_name)
VALUES
(:mid, ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326), :geom_type,
:category, :title, :description, :author_name)
");
$stmt->execute([
':mid' => $input['municipality_id'],
':geom' => $input['geom'],
':geom_type' => $input['geom_type'],
':category' => $input['category'],
':title' => $input['title'],
':description' => $input['description'] ?? '',
':author_name' => $input['author_name']
]);
json_response([
'message' => 'Contribution created successfully.',
'contribution_id' => (int) $pdo->lastInsertId()
], 201);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// UPDATE: Updates existing Contributions
// Required: contribution_id
// Optional: category, title, description, status
// Provided Fields are updated. Others remain unchanged.
// ---------------------------------------------------------------------
function handle_update($input) {
$pdo = get_db();
// Validates Input
$missing = validate_required($input, ['contribution_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$contribution_id = $input['contribution_id'];
// Checks if Contribution exists
$stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id");
$stmt->execute([':id' => $contribution_id]);
if (!$stmt->fetch()) {
error_response('Contribution not found.', 404);
}
// Builds dynamic SQL Query to only update sent Fields
$updatable_fields = ['category', 'title', 'description', 'status'];
$set_clauses = [];
$params = [':id' => $contribution_id];
foreach ($updatable_fields as $field) {
if (isset($input[$field]) && $input[$field] !== '') {
$set_clauses[] = "$field = :$field";
$params[":$field"] = $input[$field];
}
}
if (empty($set_clauses)) {
error_response('No Fields to update. Provide at least one of: ' . implode(', ', $updatable_fields));
}
// Validates Status
if (isset($params[':status'])) {
$valid_statuses = ['pending', 'approved', 'rejected', 'in_progress', 'done'];
if (!in_array($params[':status'], $valid_statuses)) {
error_response('Invalid Status. Must be: ' . implode(', ', $valid_statuses));
}
}
// Builds SQL Statement
$sql = "UPDATE contributions SET " . implode(', ', $set_clauses) . " WHERE contribution_id = :id";
// Prepared SQL Statement
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
json_response(['message' => 'Contribution updated successfully.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// DELETE: Deletes existing Contributions
// Required: contribution_id
// Associated Votes are deleted automatically
// ---------------------------------------------------------------------
function handle_delete($input) {
$pdo = get_db();
// Validates Input
$missing = validate_required($input, ['contribution_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$contribution_id = $input['contribution_id'];
// Checks if Contribution exists
$stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id");
$stmt->execute([':id' => $contribution_id]);
if (!$stmt->fetch()) {
error_response('Contribution not found.', 404);
}
// Prepared SQL Statement
try {
$stmt = $pdo->prepare("DELETE FROM contributions WHERE contribution_id = :id");
$stmt->execute([':id' => $contribution_id]);
json_response(['message' => 'Contribution deleted successfully.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// VOTE: Likes or Dislikes a Contribution
// Required: contribution_id, voter_name, vote_type
// Database Trigger automatically updates Likes and Dislikes Count
// UNIQUE Constraint prevents duplicate Votes per Voter.
// ---------------------------------------------------------------------
function handle_vote($input) {
$pdo = get_db();
// Validates Input
$missing = validate_required($input, ['contribution_id', 'voter_name', 'vote_type']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
// Validates Vote Type
$valid_vote_types = ['like', 'dislike'];
if (!in_array($input['vote_type'], $valid_vote_types)) {
error_response('Invalid vote_type. Must be: ' . implode(', ', $valid_vote_types));
}
// Checks if Contribution exists
$stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id");
$stmt->execute([':id' => $input['contribution_id']]);
if (!$stmt->fetch()) {
error_response('Contribution not found.', 404);
}
// Prepared SQL Statement
try {
$stmt = $pdo->prepare("
INSERT INTO votes (contribution_id, voter_name, vote_type)
VALUES (:cid, :voter, :vtype)
");
$stmt->execute([
':cid' => $input['contribution_id'],
':voter' => $input['voter_name'],
':vtype' => $input['vote_type']
]);
json_response(['message' => 'Vote recorded successfully.'], 201);
} catch (PDOException $e) {
// UNIQUE Constraint Violation - Voter already voted on this Contribution
if ($e->getCode() == '23505') {
error_response('You have already voted on this Contribution.', 409);
}
error_response('Database Error: ' . $e->getMessage(), 500);
}
}

94
api/db.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
// =====================================================================
// Database Helper
// Provides PDO Connection to Database and shared miscellaneous
// Functions for all API Endpoints.
// =====================================================================
require_once __DIR__ . '/init.php';
// ---------------------------------------------------------------------
// JSON Response
// Creates JSON Response including HTTP Status Code and HTTP Header
// for every API Endpoint and terminates the Script.
// ---------------------------------------------------------------------
function json_response($data, $status_code = 200) {
// Defines HTTP Status Code and HTTP Header
// 1XX Informational, 2XX Successful, 3XX Redirection,
// 4XX Client Error, 5XX Server Error
http_response_code($status_code);
header('Content-Type: application/json; charset=utf-8');
// Converts PHP-Array to JSON-String
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// ---------------------------------------------------------------------
// Error Response
// Creates standardized Error Responses with Error Message and HTTP Status
// Code. Uses json_response() for consistent Formatting.
// ---------------------------------------------------------------------
function error_response($message, $status_code = 400) {
json_response(['error' => $message], $status_code);
}
// ---------------------------------------------------------------------
// Validate Required Fields
// Checks if specified Fields exist in the given Data Array and are
// non-empty. Returns an Array of missing Field Names, or an empty
// Array if all Fields are present.
// ---------------------------------------------------------------------
function validate_required($data, $fields) {
$missing = [];
foreach ($fields as $field) {
// Checks if Fields exists in Data Array and are not empty
if (!isset($data[$field]) || trim($data[$field]) === '') {
$missing[] = $field;
}
}
// Returns Array of missing Fields or emty Array
return $missing;
}
// ---------------------------------------------------------------------
// Get POST Input
// Reads POST Parameters. Returns an associative Array.
// Fallback to JSON Request Body if no POST Data is present.
// ---------------------------------------------------------------------
function get_input() {
// Checks for standard POST Requests
if (!empty($_POST)) {
return array_map('trim', $_POST);
}
// Fall back for JSON POST Requests
$json = file_get_contents('php://input');
$data = json_decode($json, true);
if (is_array($data)) {
return array_map('trim', $data);
}
return [];
}
// ---------------------------------------------------------------------
// Get PDO Connection
// Returns PDO Instance wrapped in a Function to prevent global
// Variable Dependencies in Endpoint Files.
// ---------------------------------------------------------------------
function get_db() {
global $pdo;
if (!$pdo) {
error_response('Database Connection failed.', 500);
}
return $pdo;
}

44
api/init.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
// =====================================================================
// Database Connection
// =====================================================================
// Reads Environment Configfile
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
list($key, $value) = array_map('trim', explode('=', $line, 2));
putenv("$key=$value");
}
}
// Defines Environment Variables
$host = getenv('POSTGRES_HOST');
$port = getenv('POSTGRES_PORT');
$db = getenv('POSTGRES_DB');
$user = getenv('POSTGRES_USER');
$pass = getenv('POSTGRES_PASSWORD');
// Output Buffering and Session Start
ob_start();
session_start();
// Initializes Database Connection
try {
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
];
$dsn = "pgsql:host=$host;dbname=$db;port=$port";
$pdo = new PDO($dsn, $user, $pass, $opt);
// Creates Error Message
} catch(PDOException $e) {
echo "Error: ".$e->getMessage();
}
?>

View File

@@ -1,56 +0,0 @@
<?php
$host = 'webgis-db'; // Matches the service name in docker-compose
$db = getenv('POSTGRES_DB');
$user = getenv('POSTGRES_USER');
$pass = getenv('POSTGRES_PASSWORD');
try {
$dsn = "pgsql:host=$host;port=5432;dbname=$db;";
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if ($pdo) {
echo "<h1>✅ Connected to PostGIS!</h1>";
// Check PostGIS version
$query = $pdo->query("SELECT PostGIS_full_version();");
$version = $query->fetch();
echo "<p><strong>PostGIS Version:</strong> " . $version[0] . "</p>";
}
} catch (PDOException $e) {
echo "<h1>❌ Connection Failed</h1>";
echo "<p>" . $e->getMessage() . "</p>";
}
?>
<?php
// ========================================================================================
// INIT.PHP — Datenbankverbindung und Session-Initialisierung
// ========================================================================================
//
// ob_start();
// session_start();
// try {
// $opt = [
// PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// PDO::ATTR_EMULATE_PREPARES => false
// ];
// $dsn = "pgsql:host=localhost;dbname=getenv('POSTGRES_DB');port=5432";
// $pdo = new PDO($dsn, getenv('POSTGRES_USER'), 'getenv('POSTGRES_PASSWORD'), $opt);
// } catch(PDOException $e) {
// echo "Error: ".$e->getMessage();
// }
?>

48
legacy/delete_data.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
// ToDo's
// Whitelists oder Prepared Statements gegen SQL-Injection hinzufügen
include 'init.php';
$request = htmlspecialchars($_POST['request'], ENT_QUOTES);
if ($request=='buildings') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
try {
$pdo -> query("DELETE FROM buildings WHERE webgis_id = '$webgis_id'");
} catch (PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
if ($request == 'pipelines') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
try {
$pdo -> query("DELETE from pipelines where webgis_id= '$webgis_id' ");
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
if ($request == 'valves') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
try {
$pdo -> query("DELETE from valves where webgis_id= '$webgis_id' ");
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
?>

52
legacy/find_data.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
// ToDo's
// Whitelists oder Prepared Statements gegen SQL-Injection hinzufügen
// PostgreSQL-Serververbindung
include 'init.php';
// HTTP-POST-Methode für Formulardaten
$table = htmlspecialchars($_POST['table'], ENT_QUOTES);
$field = htmlspecialchars($_POST['field'], ENT_QUOTES);
$value = htmlspecialchars($_POST['value'], ENT_QUOTES);
try {
// Datenbankabfrage
$result = $pdo -> query("SELECT *, ST_AsGeoJSON(geom) as geojson FROM $table WHERE $field = '$value'");
$features = [];
foreach($result as $row) {
// PHP-Objekt erstellen
$geometry = json_decode($row['geojson']);
// PHP-Objekt bereinigen
unset($row['geom']);
unset($row['geojson']);
// JSON-Feature hinzufügen
$feature = [
"type"=>"Feature",
"geometry"=>$geometry,
"properties"=>$row
];
array_push($features, $feature);
};
// Feature-Collection hinzufügen
$featureCollection = [
"type"=>"FeatureCollection",
"features"=>$features
];
echo json_encode($featureCollection);
// Fehlernachricht ausgeben
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
?>

73
legacy/insert_data.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
// ToDo's
// Whitelists oder Prepared Statements gegen SQL-Injection hinzufügen
// PostgreSQL-Serververbindung
include 'init.php';
$request = htmlspecialchars($_POST['request'], ENT_QUOTES);
if ($request == 'valves') {
$valve_id = htmlspecialchars($_POST['valve_id'], ENT_QUOTES);
$valve_type = htmlspecialchars($_POST['valve_type'], ENT_QUOTES);
$valve_dma_id = htmlspecialchars($_POST['valve_dma_id'], ENT_QUOTES);
$valve_diameter = htmlspecialchars($_POST['valve_diameter'], ENT_QUOTES);
$valve_visibility = htmlspecialchars($_POST['valve_visibility'], ENT_QUOTES);
$valve_location = htmlspecialchars($_POST['valve_location'], ENT_QUOTES);
$valve_geometry = $_POST['valve_geometry'];
$result = $pdo -> query("SELECT * FROM valves WHERE valve_id = '$valve_id'");
if ($result->rowCount()>0) {
echo "ERROR: Valve ID already exists. Please type in another ID!";
} else {
// Datenbankabfrage
$result = $pdo -> query("INSERT INTO valves(valve_id, valve_type, valve_dma_id, valve_diameter, valve_location, valve_visibility, geom) VALUES ('$valve_id', '$valve_type', '$valve_dma_id', '$valve_diameter', '$valve_location', '$valve_visibility', ST_SetSRID(ST_GeomFromGeoJSON('$valve_geometry'), 4326))");
}
}
if ($request == 'pipelines') {
$pipeline_id = htmlspecialchars($_POST['pipeline_id'], ENT_QUOTES);
$pipeline_category = htmlspecialchars($_POST['pipeline_category'], ENT_QUOTES);
$pipeline_dma_id = htmlspecialchars($_POST['pipeline_dma_id'], ENT_QUOTES);
$pipeline_diameter = htmlspecialchars($_POST['pipeline_diameter'], ENT_QUOTES);
$pipeline_method = htmlspecialchars($_POST['pipeline_method'], ENT_QUOTES);
$pipeline_location = htmlspecialchars($_POST['pipeline_location'], ENT_QUOTES);
$pipeline_geometry = $_POST['pipeline_geometry'];
$result = $pdo -> query("SELECT * FROM pipelines WHERE pipeline_id = '$pipeline_id'");
if ($result->rowCount()>0) {
echo "ERROR: Pipeline ID already exists. Please type in another ID!";
} else {
// Datenbankabfrage
$result = $pdo -> query("INSERT INTO pipelines(pipeline_id, pipeline_category, pipeline_dma_id, pipeline_diameter, pipeline_method, pipeline_location, geom) VALUES ('$pipeline_id', '$pipeline_category', '$pipeline_dma_id', '$pipeline_diameter', '$pipeline_method', '$pipeline_location', ST_SetSRID(ST_GeomFromGeoJSON('$pipeline_geometry'), 4326))");
}
}
if ($request == 'buildings') {
$account_no = htmlspecialchars($_POST['account_no'], ENT_QUOTES);
$building_category = htmlspecialchars($_POST['building_category'], ENT_QUOTES);
$building_dma_id = htmlspecialchars($_POST['building_dma_id'], ENT_QUOTES);
$building_storey = htmlspecialchars($_POST['building_storey'], ENT_QUOTES);
$building_population = htmlspecialchars($_POST['building_population'], ENT_QUOTES);
$building_location = htmlspecialchars($_POST['building_location'], ENT_QUOTES);
$building_geometry = $_POST['building_geometry'];
$result = $pdo -> query("SELECT *from buildings where account_no= '$account_no'");
if ($result->rowCount()>0) {
echo "ERROR: Building ID already exists. Please type in another ID!";
} else {
$sql = $pdo -> query("INSERT INTO buildings(account_no, building_category, building_dma_id, building_storey, building_population, building_location, geom) VALUES ('$account_no', '$building_category', '$building_dma_id', '$building_storey', '$building_population', '$building_location', ST_Force3DZ(ST_SetSRID(ST_GeomFromGeoJSON('$building_geometry'), 4326)))");
}
}
?>

63
legacy/load_data.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
// ToDo's
// Whitelists oder Prepared Statements gegen SQL-Injection hinzufügen
// PostgreSQL-Serververbindung
include 'init.php';
// HTTP-POST-Methode für Formulardaten
$table = htmlspecialchars($_POST['table'], ENT_QUOTES);
$dma_id = htmlspecialchars($_POST['dma_id'], ENT_QUOTES);
if($table == 'valves') {
$dma_id_field = "valve_dma_id";
}
if($table == 'buildings') {
$dma_id_field = "building_dma_id";
}
if($table == 'pipelines') {
$dma_id_field = "pipeline_dma_id";
}
try {
// Datenbankabfrage
$result = $pdo -> query("SELECT *, ST_AsGeoJSON(geom) as geojson FROM $table WHERE $dma_id_field = '$dma_id'");
$features = [];
foreach($result as $row) {
// PHP-Objekt erstellen
$geometry = json_decode($row['geojson']);
// PHP-Objekt bereinigen
unset($row['geom']);
unset($row['geojson']);
// JSON-Feature hinzufügen
$feature = [
"type"=>"Feature",
"geometry"=>$geometry,
"properties"=>$row
];
array_push($features, $feature);
};
// Feature-Collection hinzufügen
$featureCollection = [
"type"=>"FeatureCollection",
"features"=>$features
];
echo json_encode($featureCollection);
// Fehlernachricht ausgeben
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
?>

97
legacy/test.html Normal file
View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<!-- jQuery UI -->
<link rel="stylesheet" href="source/jquery-ui.min.css">
<script src="source/jquery-ui.min.js"></script>
<!-- Bootstrap Stylesheet & Skript -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="styles.css">
<!-- Sidebar Plugin -->
<link rel="stylesheet" href="plugins/sidebar/leaflet-sidebar.css">
<script src="plugins/sidebar/leaflet-sidebar.js"></script>
<!-- Button Plugin -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-easybutton@2/src/easy-button.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet-easybutton@2/src/easy-button.js"></script>
<!-- Font Plugin -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- PolylineMeasure Plugin -->
<link rel="stylesheet" href="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.css">
<script src="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.js"></script>
<!-- MousePosition Plugin -->
<link rel="stylesheet" href="plugins/mouseposition/L.Control.MousePosition.css">
<script src="plugins/mouseposition/L.Control.MousePosition.js"></script>
<!-- Geoman Plugin -->
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.css">
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.js"></script>
<!-- Minimap Plugin -->
<link rel="stylesheet" href="plugins/minimap/Control.MiniMap.min.css">
<script src="plugins/minimap/Control.MiniMap.min.js"></script>
<!-- ajax Plugin -->
<script src="plugins/ajax/leaflet.ajax.js"></script>
</head>
<body>
<div class="popup-container">
<input type="hidden" name="building_database_id" class="updateBuilding" value="something">
<input type="hidden" name="account_no_old" class="updateBuilding" value="something">
<div class="popup-form-group">
<label class="control-label popup-label">Building ID</label>
<input type="text" class="form-control popup-input text-center updateBuilding" value="something" name="account_no">
</div>
<div class="popup-form-group">
<label class="control-label popup-label">Category</label>
<input type="text" class="form-control popup-input text-center updateBuilding" value="something" name="building_category">
</div>
<div class="popup-form-group">
<label class="control-label popup-label">Storey</label>
<input type="number" class="form-control popup-input text-center updateBuilding" value="something" name="building_storey">
</div>
<div class="popup-form-group">
<label class="control-label popup-label">Population</label>
<input type="number" class="form-control popup-input text-center updateBuilding" value="something" name="building_population">
</div>
<div class="popup-form-group">
<label class="control-label popup-label">Location</label>
<input type="text" class="form-control popup-input text-center updateBuilding" value="something" name="building_locationn">
</div>
<div class="popup-button-group">
<button type="submit" class="btn btn-success popup-button">Update</button>
<button type="submit" class="btn btn-danger popup-button">Delete</button>
</div>
</div>
</body>
</html>

98
legacy/update_data.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
include 'init.php';
$request = htmlspecialchars($_POST['request'], ENT_QUOTES);
if ($request=='buildings') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
$account_no_old = htmlspecialchars($_POST['account_no_old'], ENT_QUOTES);
$account_no = htmlspecialchars($_POST['account_no'], ENT_QUOTES);
$building_category = htmlspecialchars($_POST['building_category'], ENT_QUOTES);
$building_storey = htmlspecialchars($_POST['building_storey'], ENT_QUOTES);
$building_population = htmlspecialchars($_POST['building_population'], ENT_QUOTES);
$building_location = htmlspecialchars($_POST['building_location'], ENT_QUOTES);
$building_dma_id = htmlspecialchars($_POST['building_dma_id'], ENT_QUOTES);
try {
if ($account_no_old != $account_no) {
$result = $pdo -> query("SELECT * FROM buildings WHERE account_no = '$account_no'");
if ($result -> rowCount()>0) {
echo "ERROR: Account Number already exists. Pleas type in another Account Number!";
} else {
$pdo -> query("UPDATE buildings SET account_no = '$account_no', building_category = '$building_category', building_storey = '$building_storey', building_population = '$building_population', building_location = '$building_location', building_dma_id = '$building_dma_id' WHERE webgis_id = '$webgis_id'");
}
} else { $pdo -> query("UPDATE buildings SET account_no = '$account_no', building_category = '$building_category', building_storey = '$building_storey', building_population = '$building_population', building_location = '$building_location', building_dma_id = '$building_dma_id' WHERE webgis_id = '$webgis_id'");
}
} catch (PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
if ($request == 'pipelines') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
$pipeline_id_old = htmlspecialchars($_POST['pipeline_id_old'], ENT_QUOTES);
$pipeline_id = htmlspecialchars($_POST['pipeline_id'], ENT_QUOTES);
$pipeline_dma_id = htmlspecialchars($_POST['pipeline_dma_id'], ENT_QUOTES);
$pipeline_diameter = htmlspecialchars($_POST['pipeline_diameter'], ENT_QUOTES);
$pipeline_location = htmlspecialchars($_POST['pipeline_location'], ENT_QUOTES);
$pipeline_category = htmlspecialchars($_POST['pipeline_category'], ENT_QUOTES);
$pipeline_length = htmlspecialchars($_POST['pipeline_length'], ENT_QUOTES);
try {
if ($pipeline_id_old != $pipeline_id) {
$result = $pdo -> query("SELECT *from pipelines where pipeline_id = '$pipeline_id' ");
if ($result -> rowCount()>0) {
echo "ERROR: Pipeline ID already exists. Please choose a new ID";
} else {
$pdo -> query("UPDATE pipelines set pipeline_id = '$pipeline_id', pipeline_dma_id = '$pipeline_dma_id', pipeline_diameter = '$pipeline_diameter', pipeline_location = '$pipeline_location', pipeline_category='$pipeline_category', pipeline_length='$pipeline_length' where webgis_id = '$webgis_id'");
}
} else {
$pdo -> query("UPDATE pipelines set pipeline_id = '$pipeline_id', pipeline_dma_id = '$pipeline_dma_id', pipeline_diameter = '$pipeline_diameter', pipeline_location = '$pipeline_location', pipeline_category='$pipeline_category', pipeline_length='$pipeline_length' where webgis_id = '$webgis_id'");
}
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
if ($request == 'valves') {
$webgis_id = htmlspecialchars($_POST['webgis_id'], ENT_QUOTES);
$valve_id_old = htmlspecialchars($_POST['valve_id_old'], ENT_QUOTES);
$valve_id = htmlspecialchars($_POST['valve_id'], ENT_QUOTES);
$valve_dma_id = htmlspecialchars($_POST['valve_dma_id'], ENT_QUOTES);
$valve_type = htmlspecialchars($_POST['valve_type'], ENT_QUOTES);
$valve_diameter = htmlspecialchars($_POST['valve_diameter'], ENT_QUOTES);
$valve_location = htmlspecialchars($_POST['valve_location'], ENT_QUOTES);
$valve_visibility = htmlspecialchars($_POST['valve_visibility'], ENT_QUOTES);
try {
if ($valve_id_old != $valve_id) {
$result = $pdo -> query("SELECT *from valves where valve_id = '$valve_id' ");
if ($result -> rowCount()>0) {
echo "ERROR: Valve ID already exists. Please choose a new ID";
} else {
$pdo -> query("UPDATE valves set valve_id = '$valve_id', valve_dma_id = '$valve_dma_id', valve_type = '$valve_type', valve_diameter = '$valve_diameter', valve_location = '$valve_location', valve_visibility = '$valve_visibility' where webgis_id = '$webgis_id' ");
}
} else {
$pdo -> query("UPDATE valves set valve_id = '$valve_id', valve_dma_id = '$valve_dma_id', valve_type = '$valve_type', valve_diameter = '$valve_diameter', valve_location = '$valve_location', valve_visibility = '$valve_visibility' where webgis_id = '$webgis_id' ");
}
} catch(PDOException $e) {
echo "ERROR ".$e->getMessage();
}
}
?>

View File

@@ -0,0 +1,169 @@
-- =====================================================================
-- WebGIS Citizen Participation Portal — Initial Schema
-- Migration: 001_initial_schema.sql
-- Description: Creates Core Tables for a multi-tenant Citizen
-- Participation Platform with Point/Line/Polygon
-- Contributions, Voting, and Moderation Workflow.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Block 1: Checks PostGIS Extension
-- ---------------------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS postgis;
-- ---------------------------------------------------------------------
-- Block 2: Creates Table "municipalities"
-- One Row per Municipalitiy using the Portal (multi-tenant setup).
-- ---------------------------------------------------------------------
CREATE TABLE municipalities (
municipality_id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE, -- Municipalitiy Name
slug VARCHAR(50) NOT NULL UNIQUE, -- URL-safe Identifier, e.g. "lohne"
center_lat DOUBLE PRECISION NOT NULL, -- Map Center Latitude
center_lng DOUBLE PRECISION NOT NULL, -- Map Center Longitude
default_zoom SMALLINT NOT NULL DEFAULT 13, -- Map Default Zoom Level
logo_path VARCHAR(255), -- Relative Path to Municipality Logo
primary_color VARCHAR(7) DEFAULT '#6a6a6a', -- HexColor for UI Theme
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE municipalities IS 'Configuration Per Municipality (Tenant) using the Citizen Participation Portal.';
-- ---------------------------------------------------------------------
-- Block 3: Table "contributions"
-- Aitizen and Administration Contributions as Points, Lines, and
-- Polygons stored together in one mixed-geometry Column.
-- ---------------------------------------------------------------------
CREATE TABLE contributions (
contribution_id SERIAL PRIMARY KEY,
municipality_id INTEGER NOT NULL REFERENCES municipalities(municipality_id) ON DELETE CASCADE,
geom GEOMETRY(Geometry, 4326) NOT NULL, -- Mixed Geometry: Point, Line, Polygon, ... (WGS84)
geom_type VARCHAR(20) NOT NULL, -- 'point' | 'line' | 'polygon'
category VARCHAR(50) NOT NULL, -- Contribution Category
title VARCHAR(200) NOT NULL,
description TEXT,
author_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
likes_count INTEGER NOT NULL DEFAULT 0,
dislikes_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT contributions_geom_type_check
CHECK (geom_type IN ('point', 'line', 'polygon')),
CONSTRAINT contributions_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'in_progress', 'done'))
);
COMMENT ON TABLE contributions IS 'Citizen and Administration Contributions with mixed Geometry Types.';
-- ---------------------------------------------------------------------
-- Block 4: Indexes for fast Queries
-- ---------------------------------------------------------------------
CREATE INDEX contributions_geom_idx ON contributions USING GIST (geom);
CREATE INDEX contributions_municipality_idx ON contributions (municipality_id);
CREATE INDEX contributions_status_idx ON contributions (status);
CREATE INDEX contributions_category_idx ON contributions (category);
-- ---------------------------------------------------------------------
-- Block 5: Table "votes"
-- Individual like and dislike Records. UNIQUE Constraint prevents the
-- same voter from liking or disliking the same contribution multiple times.
-- ---------------------------------------------------------------------
CREATE TABLE votes (
vote_id SERIAL PRIMARY KEY,
contribution_id INTEGER NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE,
voter_name VARCHAR(100) NOT NULL, -- ToDo: Replace with user_id once Authentification exists
vote_type VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT votes_unique_per_voter UNIQUE (contribution_id, voter_name),
CONSTRAINT votes_vote_type_check CHECK (vote_type IN ('like', 'dislike'))
);
COMMENT ON TABLE votes IS 'Individual Votes to prevent duplicate Likes and Dislikes.';
-- ---------------------------------------------------------------------
-- Block 6: Trigger Functions
-- ---------------------------------------------------------------------
-- Automatically Refresh updated_at on every UPDATE.
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER contributions_updated_at
BEFORE UPDATE ON contributions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER municipalities_updated_at
BEFORE UPDATE ON municipalities
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Keeps likes_count / dislikes_count synchronized with the votes Table.
CREATE OR REPLACE FUNCTION update_vote_counts()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.vote_type = 'like' THEN
UPDATE contributions SET likes_count = likes_count + 1
WHERE contribution_id = NEW.contribution_id;
ELSE
UPDATE contributions SET dislikes_count = dislikes_count + 1
WHERE contribution_id = NEW.contribution_id;
END IF;
ELSIF TG_OP = 'DELETE' THEN
IF OLD.vote_type = 'like' THEN
UPDATE contributions SET likes_count = GREATEST(likes_count - 1, 0)
WHERE contribution_id = OLD.contribution_id;
ELSE
UPDATE contributions SET dislikes_count = GREATEST(dislikes_count - 1, 0)
WHERE contribution_id = OLD.contribution_id;
END IF;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER votes_count_sync
AFTER INSERT OR DELETE ON votes
FOR EACH ROW EXECUTE FUNCTION update_vote_counts();
-- ---------------------------------------------------------------------
-- Block 7: Typed Geometry Views for QGIS
-- QGIS handles mixed-geometry Tables awkwardly, so one View per
-- Geometry Type is created. Reflects live Data from the Contributions Table.
-- ---------------------------------------------------------------------
CREATE VIEW contributions_points AS
SELECT * FROM contributions WHERE geom_type = 'point';
CREATE VIEW contributions_lines AS
SELECT * FROM contributions WHERE geom_type = 'line';
CREATE VIEW contributions_polygons AS
SELECT * FROM contributions WHERE geom_type = 'polygon';
-- ---------------------------------------------------------------------
-- Block 8: Seed Data — Initial Municipality
-- ---------------------------------------------------------------------
INSERT INTO municipalities (name, slug, center_lat, center_lng, default_zoom, primary_color)
VALUES ('Lohne (Oldenburg)', 'lohne', 52.66639, 8.23306, 14, '#00376D');
-- =====================================================================
-- End of migration 001_initial_schema.sql
-- =====================================================================

View File

@@ -0,0 +1,48 @@
-- =====================================================================
-- WebGIS Citizen Participation Portal
-- Migration: 002_add_votes_index.sql
-- Description: Adds missing Index on votes.contribution_id for fast
-- Vote Lookups per Contribution.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Block 1: Index for fast Queries
-- The UNIQUE Constraint on contribution_id and voter_name creates a
-- composite Index, but Queries filtering only by contribution_id
-- cannot use it efficiently. This single-column Index covers that Case.
-- ---------------------------------------------------------------------
CREATE INDEX votes_contribution_idx ON votes (contribution_id);
-- =====================================================================
-- ToDo's for future Migrations
-- =====================================================================
--
-- 1. Categories Table
-- Create a "categories" Table with municipality_id, slug, label,
-- icon (FontAwesome), color, and sort_order. Replace the free-text
-- "category" Column in Contributions with a Foreign Key Reference.
-- This prevents Typos and inconsistent Category Names, and allows
-- each Municipality to define its own Set of Categories.
--
-- 2. Soft Delete
-- Add "deleted_at TIMESTAMPTZ DEFAULT NULL" to Contributions.
-- Instead of DELETE, set deleted_at = NOW(). Filter all Queries
-- with "WHERE deleted_at IS NULL". Allows Moderation Audit Trail
-- and accidental Deletion Recovery.
--
-- 3. Audit Log
-- Create an "audit_log" Table recording who changed what and when.
-- Columns: audit_id, table_name, record_id, action (insert/update/
-- delete), changed_by, old_values (JSONB), new_values (JSONB),
-- created_at. Populate via Triggers on Contributions and Votes.
--
-- 4. Geometry Validation
-- Add CHECK Constraint "ST_IsValid(geom)" on Contributions, or
-- validate in the API Layer before Insert. Prevents self-crossing
-- Polygons and other invalid Geometries.
--
-- =====================================================================
-- End of migration 002_add_votes_index.sql
-- =====================================================================

352
public/index.php Normal file
View File

@@ -0,0 +1,352 @@
<?php
// =====================================================================
// WebGIS Citizen Participation Portal — Main Page
// Loads Municipality Configuration from the Database and renders the
// Map Interface with Header, Sidebar, and Footer.
// =====================================================================
require_once __DIR__ . '/../api/db.php';
// -----------------------------------------------------------------
// Load Municipality Configuration
// ToDo: Make dynamic via URL Slug (e.g. /lohne) once multi-tenant
// Routing is implemented. Hardcoded to Slug 'lohne' for now.
// -----------------------------------------------------------------
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM municipalities WHERE slug = :slug");
$stmt->execute([':slug' => 'lohne']);
$municipality = $stmt->fetch();
if (!$municipality) {
die("Municipality not found.");
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bürgerbeteiligung <?= htmlspecialchars($municipality['name']) ?></title>
<!-- ============================================================= -->
<!-- CSS Dependencies -->
<!-- ============================================================= -->
<!-- Leaflet 1.9.4 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<!-- Geoman Drawing Tools -->
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.css">
<!-- Leaflet Sidebar v2 -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-sidebar-v2@3.2.3/css/leaflet-sidebar.min.css">
<!-- Leaflet Fullscreen -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.css">
<!-- Leaflet Geocoder (Address Search) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.css">
<!-- Leaflet PolylineMeasure -->
<link rel="stylesheet" href="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.css">
<!-- SweetAlert2 (Confirmation Dialogs) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<!-- Font Awesome 6 (Icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Application Styles -->
<link rel="stylesheet" href="styles.css">
<!-- ============================================================= -->
<!-- Municipality Theme (loaded from Database) -->
<!-- ============================================================= -->
<style>
:root {
--color-primary: <?= htmlspecialchars($municipality['primary_color']) ?>;
--color-primary-light: <?= htmlspecialchars($municipality['primary_color']) ?>22;
--color-primary-dark: <?= htmlspecialchars($municipality['primary_color']) ?>;
--map-center-lat: <?= $municipality['center_lat'] ?>;
--map-center-lng: <?= $municipality['center_lng'] ?>;
--map-default-zoom: <?= $municipality['default_zoom'] ?>;
}
</style>
</head>
<body>
<!-- ============================================================= -->
<!-- Header -->
<!-- ============================================================= -->
<header id="app-header">
<div class="header-left">
<img src="assets/logo-municipality.png" alt="<?= htmlspecialchars($municipality['name']) ?>" class="header-logo" onerror="this.style.display='none'">
<h1 class="header-title">Bürgerbeteiligung <?= htmlspecialchars($municipality['name']) ?></h1>
</div>
<nav class="header-nav">
<button class="nav-btn" onclick="showInfoModal()">
<i class="fa-solid fa-circle-info"></i>
<span class="nav-label">Info</span>
</button>
<button class="nav-btn" onclick="showPrivacyModal()">
<i class="fa-solid fa-shield-halved"></i>
<span class="nav-label">Datenschutz</span>
</button>
<button class="nav-btn" onclick="showImprintModal()">
<i class="fa-solid fa-scale-balanced"></i>
<span class="nav-label">Impressum</span>
</button>
</nav>
<!-- Mobile Hamburger Menu -->
<button class="header-menu-toggle" onclick="toggleMobileNav()">
<i class="fa-solid fa-bars"></i>
</button>
</header>
<!-- ============================================================= -->
<!-- Map Container with Sidebar -->
<!-- ============================================================= -->
<main id="app-main">
<!-- Leaflet Sidebar v2 -->
<div id="sidebar" class="leaflet-sidebar collapsed">
<!-- Sidebar Tab Icons -->
<div class="leaflet-sidebar-tabs">
<ul role="tablist">
<li><a href="#tab-home" role="tab"><i class="fa-solid fa-house"></i></a></li>
<li><a href="#tab-list" role="tab"><i class="fa-solid fa-list"></i></a></li>
<li><a href="#tab-help" role="tab"><i class="fa-solid fa-circle-question"></i></a></li>
<li><a href="#tab-news" role="tab"><i class="fa-solid fa-newspaper"></i></a></li>
</ul>
</div>
<!-- Sidebar Tab Content -->
<div class="leaflet-sidebar-content">
<!-- Home Tab -->
<div class="leaflet-sidebar-pane" id="tab-home">
<h2 class="leaflet-sidebar-header">
Übersicht
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<p>Willkommen beim Bürgerbeteiligungsportal <strong><?= htmlspecialchars($municipality['name']) ?></strong>.</p>
<p>Nutzen Sie die Karte, um Hinweise und Aufgaben zu erstellen oder bestehende Beiträge einzusehen.</p>
<h3>Kategorien</h3>
<div id="category-filter">
<!-- Category Filter Checkboxes — populated by app.js -->
</div>
<h3>Statistik</h3>
<div id="stats-container">
<!-- Contribution Statistics — populated by app.js -->
</div>
</div>
</div>
<!-- List Tab -->
<div class="leaflet-sidebar-pane" id="tab-list">
<h2 class="leaflet-sidebar-header">
Beiträge
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<div class="list-search">
<input type="text" id="list-search-input" placeholder="Beiträge durchsuchen..." class="form-input">
</div>
<div id="contributions-list">
<!-- Contribution Cards — populated by app.js -->
</div>
</div>
</div>
<!-- Help Tab -->
<div class="leaflet-sidebar-pane" id="tab-help">
<h2 class="leaflet-sidebar-header">
Hilfe
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<h3><i class="fa-solid fa-map-location-dot"></i> Karte bedienen</h3>
<p>Verschieben Sie die Karte per Mausklick und Ziehen. Zoomen Sie mit dem Mausrad oder den Zoom-Buttons.</p>
<h3><i class="fa-solid fa-plus"></i> Beitrag erstellen</h3>
<p>Nutzen Sie die Zeichenwerkzeuge rechts auf der Karte, um einen Punkt, eine Linie oder eine Fläche zu zeichnen. Anschließend können Sie Kategorie und Beschreibung eingeben.</p>
<h3><i class="fa-solid fa-thumbs-up"></i> Abstimmen</h3>
<p>Klicken Sie auf einen bestehenden Beitrag und nutzen Sie die Like/Dislike-Buttons, um Ihre Meinung zu äußern.</p>
<h3><i class="fa-solid fa-magnifying-glass"></i> Suchen</h3>
<p>Nutzen Sie die Adresssuche oben rechts auf der Karte, um einen bestimmten Ort zu finden.</p>
</div>
</div>
<!-- News Tab -->
<div class="leaflet-sidebar-pane" id="tab-news">
<h2 class="leaflet-sidebar-header">
Neuigkeiten
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<div class="news-item">
<span class="news-date">April 2026</span>
<h3>Portal gestartet</h3>
<p>Das Bürgerbeteiligungsportal für <?= htmlspecialchars($municipality['name']) ?> ist online. Wir freuen uns auf Ihre Hinweise und Vorschläge!</p>
</div>
<!-- More News Items can be added here or loaded from Database -->
</div>
</div>
</div>
</div>
<!-- Map -->
<div id="map"></div>
</main>
<!-- ============================================================= -->
<!-- Footer -->
<!-- ============================================================= -->
<footer id="app-footer">
<div class="footer-content">
<img src="assets/logo-company.png" alt="Company Logo" class="footer-logo" onerror="this.style.display='none'">
<span class="footer-text">© <?= date('Y') ?> <?= htmlspecialchars($municipality['name']) ?> — Bürgerbeteiligungsportal</span>
</div>
</footer>
<!-- ============================================================= -->
<!-- Welcome Modal (shown on first Visit) -->
<!-- ============================================================= -->
<div id="welcome-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h2><i class="fa-solid fa-hand-wave"></i> Willkommen!</h2>
<p>Herzlich willkommen beim Bürgerbeteiligungsportal <strong><?= htmlspecialchars($municipality['name']) ?></strong>.</p>
<p>Hier können Sie:</p>
<ul>
<li>Hinweise und Verbesserungsvorschläge auf der Karte eintragen</li>
<li>Bestehende Beiträge einsehen und bewerten</li>
<li>Aufgaben der Stadtverwaltung unterstützen</li>
</ul>
<p>Zum Erstellen von Beiträgen geben Sie bitte zunächst Ihren Namen ein.</p>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeWelcomeAndShowLogin()">Loslegen</button>
</div>
</div>
</div>
<!-- ============================================================= -->
<!-- Login Modal (Name Entry — later: full Authentication) -->
<!-- ============================================================= -->
<div id="login-modal" class="modal-overlay" style="display:none;">
<div class="modal-content modal-small">
<h2><i class="fa-solid fa-user"></i> Anmelden</h2>
<p>Bitte geben Sie Ihren Namen ein, um Beiträge erstellen und abstimmen zu können.</p>
<div class="form-group">
<label for="user-name-input">Ihr Name</label>
<input type="text" id="user-name-input" class="form-input" placeholder="Vor- und Nachname">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="skipLogin()">Nur ansehen</button>
<button class="btn btn-primary" onclick="submitLogin()">Anmelden</button>
</div>
</div>
</div>
<!-- ============================================================= -->
<!-- Create Contribution Modal -->
<!-- ============================================================= -->
<div id="create-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h2><i class="fa-solid fa-plus-circle"></i> Neuer Beitrag</h2>
<div class="form-group">
<label for="create-category">Kategorie</label>
<select id="create-category" class="form-input">
<option value="">— Bitte wählen —</option>
<option value="mobility">🚲 Mobilität</option>
<option value="building">🏗️ Bauen</option>
<option value="energy">⚡ Energie</option>
<option value="environment">🌳 Umwelt</option>
<option value="industry">🏭 Industrie</option>
<option value="consumption">🛒 Konsum</option>
<option value="other">📌 Sonstiges</option>
</select>
</div>
<div class="form-group">
<label for="create-title">Titel</label>
<input type="text" id="create-title" class="form-input" placeholder="Kurze Beschreibung des Anliegens">
</div>
<div class="form-group">
<label for="create-description">Beschreibung</label>
<textarea id="create-description" class="form-input" rows="4" placeholder="Detaillierte Beschreibung (optional)"></textarea>
</div>
<input type="hidden" id="create-geom">
<input type="hidden" id="create-geom-type">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="cancelCreate()">Abbrechen</button>
<button class="btn btn-primary" onclick="submitCreate()">Beitrag einreichen</button>
</div>
</div>
</div>
<!-- ============================================================= -->
<!-- JavaScript Dependencies -->
<!-- ============================================================= -->
<!-- Leaflet 1.9.4 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<!-- Geoman Drawing Tools -->
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.min.js"></script>
<!-- Leaflet Sidebar v2 -->
<script src="https://unpkg.com/leaflet-sidebar-v2@3.2.3/js/leaflet-sidebar.min.js"></script>
<!-- Leaflet Fullscreen -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.min.js"></script>
<!-- Leaflet Geocoder (Address Search) -->
<script src="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.min.js"></script>
<!-- Leaflet PolylineMeasure -->
<script src="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.js"></script>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.all.min.js"></script>
<!-- ============================================================= -->
<!-- Municipality Configuration (passed to JavaScript) -->
<!-- ============================================================= -->
<script>
// Municipality Configuration from Database — used by app.js
var MUNICIPALITY = {
id: <?= $municipality['municipality_id'] ?>,
name: "<?= htmlspecialchars($municipality['name'], ENT_QUOTES) ?>",
slug: "<?= htmlspecialchars($municipality['slug'], ENT_QUOTES) ?>",
center: [<?= $municipality['center_lat'] ?>, <?= $municipality['center_lng'] ?>],
zoom: <?= $municipality['default_zoom'] ?>,
primaryColor: "<?= htmlspecialchars($municipality['primary_color'], ENT_QUOTES) ?>"
};
</script>
<!-- Application Logic -->
<script src="js/app.js"></script>
</body>
</html>

890
public/js/app.js Normal file
View 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) +
' &middot; <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();

627
public/styles.css Normal file
View File

@@ -0,0 +1,627 @@
/* =====================================================================
WebGIS Citizen Participation Portal — Styles
Mobile-First Layout with CSS Custom Properties for Municipality Theming.
===================================================================== */
/* -----------------------------------------------------------------
CSS Custom Properties (Defaults — overridden per Municipality)
----------------------------------------------------------------- */
:root {
/* Municipality Colors (set dynamically in index.php) */
--color-primary: #00376D;
--color-primary-light: #00376D22;
--color-primary-dark: #00376D;
/* Neutral Colors */
--color-bg: #f4f5f7;
--color-surface: #ffffff;
--color-text: #1a1a2e;
--color-text-secondary: #5a5a7a;
--color-border: #e0e0e0;
/* Feedback Colors */
--color-success: #2e7d32;
--color-error: #c62828;
--color-warning: #f57f17;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Layout */
--header-height: 56px;
--footer-height: 40px;
--map-side-padding: 0px;
/* Typography */
--font-body: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-heading: 'Segoe UI', system-ui, -apple-system, sans-serif;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
/* -----------------------------------------------------------------
Reset and Base
----------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
font-family: var(--font-body);
font-size: 15px;
color: var(--color-text);
background: var(--color-bg);
}
/* -----------------------------------------------------------------
Header
----------------------------------------------------------------- */
#app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
background: var(--color-primary);
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.header-logo {
height: 36px;
width: auto;
object-fit: contain;
flex-shrink: 0;
}
.header-title {
font-family: var(--font-heading);
font-size: 1.1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-nav {
display: flex;
gap: var(--space-xs);
}
.nav-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: background var(--transition-fast);
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
.header-menu-toggle {
display: none;
border: none;
background: none;
color: white;
font-size: 1.4rem;
cursor: pointer;
padding: var(--space-sm);
}
/* -----------------------------------------------------------------
Main / Map Container
----------------------------------------------------------------- */
#app-main {
position: fixed;
top: var(--header-height);
bottom: var(--footer-height);
left: var(--map-side-padding);
right: var(--map-side-padding);
}
#map {
width: 100%;
height: 100%;
}
/* -----------------------------------------------------------------
Footer
----------------------------------------------------------------- */
#app-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
height: var(--footer-height);
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-md);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.footer-content {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.footer-logo {
height: 22px;
width: auto;
object-fit: contain;
}
/* -----------------------------------------------------------------
Sidebar Overrides (leaflet-sidebar-v2)
----------------------------------------------------------------- */
.leaflet-sidebar {
z-index: 999;
top: var(--header-height);
bottom: var(--footer-height);
}
.leaflet-sidebar-header {
background: var(--color-primary);
color: white;
}
.leaflet-sidebar-close {
color: white;
}
.leaflet-sidebar-tabs > ul > li > a {
color: var(--color-text-secondary);
transition: color var(--transition-fast);
}
.leaflet-sidebar-tabs > ul > li.active > a {
color: var(--color-primary);
border-color: var(--color-primary);
}
.sidebar-body {
padding: var(--space-md);
}
.sidebar-body h3 {
font-size: 0.95rem;
font-weight: 600;
margin: var(--space-lg) 0 var(--space-sm) 0;
color: var(--color-primary);
}
.sidebar-body h3:first-child {
margin-top: 0;
}
.sidebar-body p {
margin-bottom: var(--space-sm);
line-height: 1.5;
color: var(--color-text-secondary);
}
/* -----------------------------------------------------------------
Contributions List (Sidebar Tab)
----------------------------------------------------------------- */
.list-search {
margin-bottom: var(--space-md);
}
.contribution-card {
padding: var(--space-md);
margin-bottom: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.contribution-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.contribution-card-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.contribution-card-category {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-primary);
}
.contribution-card-title {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: var(--space-xs);
}
.contribution-card-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.contribution-card-votes {
display: flex;
gap: var(--space-sm);
}
/* -----------------------------------------------------------------
News Items (Sidebar Tab)
----------------------------------------------------------------- */
.news-item {
padding: var(--space-md);
margin-bottom: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
}
.news-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.news-item h3 {
font-size: 0.95rem;
margin: var(--space-xs) 0;
color: var(--color-text);
}
/* -----------------------------------------------------------------
Modals (Welcome, Login, Create Contribution)
----------------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-content {
background: var(--color-surface);
border-radius: 12px;
padding: var(--space-xl);
max-width: 480px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
}
.modal-small {
max-width: 380px;
}
.modal-content h2 {
font-family: var(--font-heading);
font-size: 1.3rem;
margin-bottom: var(--space-md);
color: var(--color-primary);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.modal-content p {
line-height: 1.6;
margin-bottom: var(--space-sm);
color: var(--color-text-secondary);
}
.modal-content ul {
margin: var(--space-sm) 0 var(--space-md) var(--space-lg);
line-height: 1.8;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-lg);
}
/* -----------------------------------------------------------------
Form Elements
----------------------------------------------------------------- */
.form-group {
margin-bottom: var(--space-md);
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: var(--space-xs);
color: var(--color-text);
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text);
background: var(--color-surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
textarea.form-input {
resize: vertical;
}
select.form-input {
cursor: pointer;
}
/* -----------------------------------------------------------------
Buttons
----------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: 10px 20px;
border: none;
border-radius: 6px;
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
min-height: 44px;
min-width: 44px;
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
filter: brightness(1.15);
}
.btn-secondary {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-border);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-danger {
background: var(--color-error);
color: white;
}
/* -----------------------------------------------------------------
Map Popup Overrides (Contribution Detail View)
----------------------------------------------------------------- */
.popup-detail {
min-width: 220px;
max-width: 300px;
}
.popup-detail-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: var(--space-xs);
}
.popup-detail-category {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
background: var(--color-primary-light);
color: var(--color-primary);
margin-bottom: var(--space-sm);
}
.popup-detail-description {
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-secondary);
margin-bottom: var(--space-sm);
}
.popup-detail-meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-bottom: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border);
}
.popup-detail-votes {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.popup-vote-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 1px solid var(--color-border);
border-radius: 20px;
background: var(--color-surface);
cursor: pointer;
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.popup-vote-btn:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.popup-vote-btn.liked {
border-color: var(--color-success);
background: #e8f5e9;
color: var(--color-success);
}
.popup-vote-btn.disliked {
border-color: var(--color-error);
background: #ffebee;
color: var(--color-error);
}
.popup-detail-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.popup-detail-actions .btn {
flex: 1;
padding: 6px 12px;
font-size: 0.8rem;
min-height: 36px;
}
/* -----------------------------------------------------------------
Mobile Responsive Overrides (max-width: 768px)
----------------------------------------------------------------- */
@media (max-width: 768px) {
:root {
--header-height: 48px;
--footer-height: 32px;
--map-side-padding: 0px;
}
.header-title {
font-size: 0.9rem;
}
.header-nav {
display: none;
position: absolute;
top: var(--header-height);
right: 0;
background: var(--color-primary);
flex-direction: column;
padding: var(--space-sm);
border-radius: 0 0 0 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.header-nav.open {
display: flex;
}
.header-menu-toggle {
display: block;
}
.nav-label {
display: inline;
}
.modal-content {
padding: var(--space-lg);
border-radius: 8px;
}
}
/* -----------------------------------------------------------------
Desktop Overrides (min-width: 769px)
----------------------------------------------------------------- */
@media (min-width: 769px) {
:root {
--map-side-padding: 8px;
}
}

View File

@@ -1 +0,0 @@
obst

129
scripts/backup.sh Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# =====================================================================
# WebGIS Database Backup Script
# Location: /opt/webgis-lohne/scripts/backup.sh (on Server)
# Purpose: Creates compressed pg_dump Backups with daily/weekly/monthly
# Rotation. Intended to be run via Cron.
# =====================================================================
# Safety Switches
set -euo pipefail
# Logs Error Messages
trap 'echo "[$(date)] ERROR: Script failed at Line ${LINENO} with Exit Code $?."' ERR
# ---------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------
DB_HOST="localhost"
DB_PORT="5432"
DB_NAME="webgis-db"
DB_USER="webgis-db-admin"
BACKUP_ROOT="/var/backups/webgis"
BACKUP_DIR_DAILY="${BACKUP_ROOT}/daily"
BACKUP_DIR_WEEKLY="${BACKUP_ROOT}/weekly"
BACKUP_DIR_MONTHLY="${BACKUP_ROOT}/monthly"
# Retention Periods in Days
KEEP_DAILY=7
KEEP_WEEKLY=28
KEEP_MONTHLY=365
# Minimum acceptable Backup File Size in Bytes
# Valid Dumps of even empty Databases are several KBs
MIN_BACKUP_SIZE=10000
# Password is read from protected File
# pg_dump honors the PGPASSFILE Environment Variable.
export PGPASSFILE="/root/.pgpass_webgis"
# ---------------------------------------------------------------------
# Preflight Checks
# ---------------------------------------------------------------------
# Check pg_dump Availability
if ! command -v pg_dump &> /dev/null; then
echo "[$(date)] ERROR: pg_dump not found. Install postgresql-client."
exit 1
fi
# Check Password File Existence and Permissions
if [[ ! -f "${PGPASSFILE}" ]]; then
echo "[$(date)] ERROR: Password File ${PGPASSFILE} not found."
exit 1
fi
PGPASS_PERMS=$(stat -c "%a" "${PGPASSFILE}")
if [[ "${PGPASS_PERMS}" != "600" ]]; then
echo "[$(date)] ERROR: ${PGPASSFILE} has Permissions ${PGPASS_PERMS}, expected 600."
exit 1
fi
# ---------------------------------------------------------------------
# Preparation
# ---------------------------------------------------------------------
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
DAY_OF_WEEK=$(date +"%u") # 1=Monday ... 7=Sunday
DAY_OF_MONTH=$(date +"%d")
mkdir -p "${BACKUP_DIR_DAILY}" "${BACKUP_DIR_WEEKLY}" "${BACKUP_DIR_MONTHLY}"
# ---------------------------------------------------------------------
# Create Daily Backup in compressed Custom Format
# ---------------------------------------------------------------------
DAILY_FILE="${BACKUP_DIR_DAILY}/webgis_${TIMESTAMP}.dump"
echo "[$(date)] Starting daily Backup -> ${DAILY_FILE}"
pg_dump \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--format=custom \
--compress=9 \
--file="${DAILY_FILE}" \
"${DB_NAME}"
# Verify Backup File Size
BACKUP_SIZE=$(stat -c "%s" "${DAILY_FILE}")
if [[ "${BACKUP_SIZE}" -lt "${MIN_BACKUP_SIZE}" ]]; then
echo "[$(date)] ERROR: Backup File is only ${BACKUP_SIZE} Bytes (Minimum: ${MIN_BACKUP_SIZE}). Dump probably corrupt."
exit 1
fi
echo "[$(date)] Daily Backup complete (${BACKUP_SIZE} Bytes)."
# ---------------------------------------------------------------------
# Promote to Weekly Backup on Sundays
# ---------------------------------------------------------------------
if [[ "${DAY_OF_WEEK}" == "7" ]]; then
cp "${DAILY_FILE}" "${BACKUP_DIR_WEEKLY}/webgis_${TIMESTAMP}.dump"
echo "[$(date)] Promoted to weekly Backup."
fi
# ---------------------------------------------------------------------
# Promote to Monthly Backup on the First of the Month
# ---------------------------------------------------------------------
if [[ "${DAY_OF_MONTH}" == "01" ]]; then
cp "${DAILY_FILE}" "${BACKUP_DIR_MONTHLY}/webgis_${TIMESTAMP}.dump"
echo "[$(date)] Promoted to monthly Backup."
fi
# ---------------------------------------------------------------------
# Rotation: Delete Backups older than Retention Period
# ---------------------------------------------------------------------
find "${BACKUP_DIR_DAILY}" -name "*.dump" -mtime +${KEEP_DAILY} -delete
find "${BACKUP_DIR_WEEKLY}" -name "*.dump" -mtime +${KEEP_WEEKLY} -delete
find "${BACKUP_DIR_MONTHLY}" -name "*.dump" -mtime +${KEEP_MONTHLY} -delete
echo "[$(date)] Backup Rotation complete."

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# WebGIS Citizen Participation Portal
Citizen Participation Portal for Lohne (Oldenburg).
## Project Structure
- `migrations/` — versioned SQL Schema Migrations
- `api/` — Backend (PHP)
- `public/` — Frontend (HTML, CSS, JS)
- `scripts/` — Maintenance Scripts (backup, deployment)
- `legacy/` — Reference Code from Prototype
## Local Setup
1. Copy `.env.example` to `.env` and fill in Database Credentials.
2. Run the SQL Migration in pgAdmin and execute in the target database.
3. Serve `public/` with a PHP-capable Web Server.
## SSH tunnel to database server
1. Create SSH Tunnel to Database Server.