Compare commits
30 Commits
main
...
dev/patric
| Author | SHA1 | Date | |
|---|---|---|---|
| 855b69f95d | |||
| 77df35926d | |||
| 65ef7f07c9 | |||
| 6eca88e941 | |||
| 801131985d | |||
| 4707e73421 | |||
| 241ec75323 | |||
| d3297d2a3c | |||
| c7e9444903 | |||
| 72315b4030 | |||
| 403d81b132 | |||
| 4f35ddeafe | |||
| 19b038d4f5 | |||
| 4554ea3ff0 | |||
| 0083a05482 | |||
| 041d1603dc | |||
| b3a4ba6d4a | |||
| 04dc118598 | |||
| dec36d4053 | |||
| d2f2b577be | |||
| a640ed1b78 | |||
| 7c0c0b5048 | |||
| 50035a524d | |||
| e8ce6c6f36 | |||
| 97ab6a52ab | |||
| b8f1c32a22 | |||
| 0aeee9a168 | |||
|
|
1f8e3935bb | ||
|
|
7bcb31a8f8 | ||
|
|
a8ab95ff3a |
6
.env.example
Normal file
6
.env.example
Normal 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
8
.gitattributes
vendored
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
.vscode/
|
||||
*.log
|
||||
326
api/contributions.php
Normal file
326
api/contributions.php
Normal 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
94
api/db.php
Normal 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
44
api/init.php
Normal 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();
|
||||
}
|
||||
|
||||
?>
|
||||
56
index.php
56
index.php
@@ -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
48
legacy/delete_data.php
Normal 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
52
legacy/find_data.php
Normal 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
73
legacy/insert_data.php
Normal 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
63
legacy/load_data.php
Normal 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
97
legacy/test.html
Normal 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
98
legacy/update_data.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
169
migrations/001_initial_schema.sql
Normal file
169
migrations/001_initial_schema.sql
Normal 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
|
||||
-- =====================================================================
|
||||
48
migrations/002_add_votes_index.sql
Normal file
48
migrations/002_add_votes_index.sql
Normal 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
352
public/index.php
Normal 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
890
public/js/app.js
Normal file
@@ -0,0 +1,890 @@
|
||||
// =====================================================================
|
||||
// WebGIS Citizen Participation Portal — Application Logic
|
||||
// Initializes the Leaflet Map, loads Contributions from the API,
|
||||
// handles the CRUD Workflow, and manages all UI Interactions.
|
||||
//
|
||||
// Depends on: MUNICIPALITY Object (set in index.php), Leaflet, Geoman,
|
||||
// Sidebar v2, Geocoder, PolylineMeasure, Fullscreen, SweetAlert2
|
||||
// =====================================================================
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 1: Configuration and Application State
|
||||
// =====================================================================
|
||||
|
||||
// API Endpoint — relative Path from public/ to api/
|
||||
var API_URL = '../api/contributions.php';
|
||||
|
||||
// Current User Name — set via Login Modal, stored in sessionStorage
|
||||
var currentUser = sessionStorage.getItem('webgis_user') || '';
|
||||
|
||||
// Category Definitions with Labels, Icons, and Colors
|
||||
var CATEGORIES = {
|
||||
mobility: { label: 'Mobilität', icon: '🚲', color: '#1565C0', faIcon: 'fa-bicycle' },
|
||||
building: { label: 'Bauen', icon: '🏗️', color: '#E65100', faIcon: 'fa-helmet-safety' },
|
||||
energy: { label: 'Energie', icon: '⚡', color: '#F9A825', faIcon: 'fa-bolt' },
|
||||
environment: { label: 'Umwelt', icon: '🌳', color: '#2E7D32', faIcon: 'fa-tree' },
|
||||
industry: { label: 'Industrie', icon: '🏭', color: '#6A1B9A', faIcon: 'fa-industry' },
|
||||
consumption: { label: 'Konsum', icon: '🛒', color: '#AD1457', faIcon: 'fa-cart-shopping' },
|
||||
other: { label: 'Sonstiges', icon: '📌', color: '#546E7A', faIcon: 'fa-map-pin' }
|
||||
};
|
||||
|
||||
// Application State
|
||||
var map; // Leaflet Map Instance
|
||||
var sidebar; // Sidebar Instance
|
||||
var contributionsLayer; // GeoJSON Layer holding all Contributions
|
||||
var contributionsData = []; // Raw Contribution Data Array
|
||||
var activeFilters = Object.keys(CATEGORIES); // Active Category Filters (all enabled by Default)
|
||||
var drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman
|
||||
var drawnGeomType = null; // Temporary Storage for Geometry Type
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 2: Map Initialization
|
||||
// =====================================================================
|
||||
|
||||
map = L.map('map', {
|
||||
center: MUNICIPALITY.center,
|
||||
zoom: MUNICIPALITY.zoom,
|
||||
zoomControl: false, // Added manually in Block 3 for Position Control
|
||||
attributionControl: true
|
||||
});
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 3: Basemaps and Layer Control
|
||||
// =====================================================================
|
||||
|
||||
// Basemap Tile Layers
|
||||
var basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 20
|
||||
});
|
||||
|
||||
var basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 20
|
||||
});
|
||||
|
||||
var basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
maxZoom: 20
|
||||
});
|
||||
|
||||
var basemapTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a>',
|
||||
maxZoom: 18
|
||||
});
|
||||
|
||||
// Set Default Basemap
|
||||
basemapCartoDB.addTo(map);
|
||||
|
||||
// Layer Control
|
||||
var basemaps = {
|
||||
'OpenStreetMap': basemapOSM,
|
||||
'CartoDB (hell)': basemapCartoDB,
|
||||
'Satellit (Esri)': basemapSatellite,
|
||||
'Topographisch': basemapTopo
|
||||
};
|
||||
|
||||
var overlays = {}; // Populated later with Contribution Layer
|
||||
|
||||
var layerControl = L.control.layers(basemaps, overlays, {
|
||||
position: 'topright',
|
||||
collapsed: true
|
||||
}).addTo(map);
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 4: Map Controls
|
||||
// =====================================================================
|
||||
|
||||
// Zoom Control (top right)
|
||||
L.control.zoom({
|
||||
position: 'topright'
|
||||
}).addTo(map);
|
||||
|
||||
// Scale Bar (bottom right)
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
maxWidth: 200,
|
||||
imperial: false
|
||||
}).addTo(map);
|
||||
|
||||
// Fullscreen Button
|
||||
L.control.fullscreen({
|
||||
position: 'topright',
|
||||
title: 'Vollbild',
|
||||
titleCancel: 'Vollbild beenden'
|
||||
}).addTo(map);
|
||||
|
||||
// Address Search (Geocoder with Nominatim)
|
||||
L.Control.geocoder({
|
||||
position: 'topright',
|
||||
placeholder: 'Adresse suchen...',
|
||||
defaultMarkGeocode: true,
|
||||
geocoder: L.Control.Geocoder.nominatim({
|
||||
geocodingQueryParams: {
|
||||
countrycodes: 'de',
|
||||
viewbox: '8.0,52.5,8.5,52.8',
|
||||
bounded: 1
|
||||
}
|
||||
})
|
||||
}).addTo(map);
|
||||
|
||||
// Polyline Measure Tool
|
||||
L.control.polylineMeasure({
|
||||
position: 'topright',
|
||||
unit: 'metres',
|
||||
showBearings: false,
|
||||
clearMeasurementsOnStop: false,
|
||||
showClearControl: true
|
||||
}).addTo(map);
|
||||
|
||||
// Mouse Position Display
|
||||
var MousePositionControl = L.Control.extend({
|
||||
options: { position: 'bottomright' },
|
||||
|
||||
onAdd: function () {
|
||||
var container = L.DomUtil.create('div', 'mouse-position-display');
|
||||
container.style.background = 'rgba(255,255,255,0.85)';
|
||||
container.style.padding = '2px 8px';
|
||||
container.style.fontSize = '12px';
|
||||
container.style.borderRadius = '4px';
|
||||
container.style.fontFamily = 'monospace';
|
||||
container.innerHTML = 'Lat: — | Lng: —';
|
||||
|
||||
map.on('mousemove', function (e) {
|
||||
container.innerHTML = 'Lat: ' + e.latlng.lat.toFixed(5) + ' | Lng: ' + e.latlng.lng.toFixed(5);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
new MousePositionControl().addTo(map);
|
||||
|
||||
// GPS Location Button
|
||||
var GpsControl = L.Control.extend({
|
||||
options: { position: 'topright' },
|
||||
|
||||
onAdd: function () {
|
||||
var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
|
||||
var button = L.DomUtil.create('a', '', container);
|
||||
button.href = '#';
|
||||
button.title = 'Mein Standort';
|
||||
button.innerHTML = '<i class="fa-solid fa-location-crosshairs"></i>';
|
||||
button.style.fontSize = '16px';
|
||||
button.style.display = 'flex';
|
||||
button.style.alignItems = 'center';
|
||||
button.style.justifyContent = 'center';
|
||||
button.style.width = '30px';
|
||||
button.style.height = '30px';
|
||||
|
||||
L.DomEvent.on(button, 'click', function (e) {
|
||||
L.DomEvent.preventDefault(e);
|
||||
map.locate({ setView: true, maxZoom: 17 });
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
new GpsControl().addTo(map);
|
||||
|
||||
// GPS Location Found Handler
|
||||
var gpsMarker = null;
|
||||
|
||||
map.on('locationfound', function (e) {
|
||||
if (gpsMarker) {
|
||||
map.removeLayer(gpsMarker);
|
||||
}
|
||||
gpsMarker = L.circleMarker(e.latlng, {
|
||||
radius: 8,
|
||||
color: '#1565C0',
|
||||
fillColor: '#42A5F5',
|
||||
fillOpacity: 0.8,
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup('Ihr Standort').openPopup();
|
||||
});
|
||||
|
||||
map.on('locationerror', function () {
|
||||
Swal.fire('Standort nicht gefunden', 'Bitte erlauben Sie den Standortzugriff in Ihrem Browser.', 'warning');
|
||||
});
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 5: Sidebar Initialization
|
||||
// =====================================================================
|
||||
|
||||
sidebar = L.control.sidebar({
|
||||
autopan: true,
|
||||
closeButton: true,
|
||||
container: 'sidebar',
|
||||
position: 'left'
|
||||
}).addTo(map);
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 6: Geoman Drawing Tools and CRUD Trigger
|
||||
// =====================================================================
|
||||
|
||||
map.pm.addControls({
|
||||
position: 'topright',
|
||||
drawMarker: true,
|
||||
drawPolyline: true,
|
||||
drawPolygon: true,
|
||||
drawCircleMarker: false,
|
||||
drawCircle: false,
|
||||
drawText: false,
|
||||
drawRectangle: false,
|
||||
editMode: false,
|
||||
dragMode: false,
|
||||
cutPolygon: false,
|
||||
removalMode: false,
|
||||
rotateMode: false
|
||||
});
|
||||
|
||||
// When a Shape is drawn, capture the Geometry and open the Create Modal
|
||||
map.on('pm:create', function (e) {
|
||||
var geojson = e.layer.toGeoJSON().geometry;
|
||||
|
||||
// Determine Geometry Type and normalize to simple Types
|
||||
if (e.shape === 'Marker') {
|
||||
drawnGeometry = { type: 'Point', coordinates: geojson.coordinates };
|
||||
drawnGeomType = 'point';
|
||||
} else if (e.shape === 'Line') {
|
||||
drawnGeometry = { type: 'LineString', coordinates: geojson.coordinates };
|
||||
drawnGeomType = 'line';
|
||||
} else if (e.shape === 'Polygon') {
|
||||
drawnGeometry = { type: 'Polygon', coordinates: geojson.coordinates };
|
||||
drawnGeomType = 'polygon';
|
||||
} else {
|
||||
// Unsupported Shape — remove from Map and exit
|
||||
map.removeLayer(e.layer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the drawn Layer — it will be re-added after API Confirmation
|
||||
map.removeLayer(e.layer);
|
||||
|
||||
// Check if User is logged in
|
||||
if (!currentUser) {
|
||||
Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um Beiträge zu erstellen.', 'info');
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate hidden Fields and open Create Modal
|
||||
document.getElementById('create-geom').value = JSON.stringify(drawnGeometry);
|
||||
document.getElementById('create-geom-type').value = drawnGeomType;
|
||||
document.getElementById('create-modal').style.display = 'flex';
|
||||
});
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 7: API Communication
|
||||
// =====================================================================
|
||||
|
||||
// Generic API Call Function
|
||||
function apiCall(data, callback) {
|
||||
var formData = new FormData();
|
||||
for (var key in data) {
|
||||
formData.append(key, data[key]);
|
||||
}
|
||||
|
||||
fetch(API_URL, { method: 'POST', body: formData })
|
||||
.then(function (response) {
|
||||
return response.json().then(function (json) {
|
||||
json._status = response.status;
|
||||
return json;
|
||||
});
|
||||
})
|
||||
.then(function (json) {
|
||||
callback(json);
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error('API Error:', error);
|
||||
Swal.fire('Verbindungsfehler', 'Die Verbindung zum Server ist fehlgeschlagen.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Load all Contributions from API and display on Map
|
||||
function loadContributions() {
|
||||
apiCall({ action: 'read', municipality_id: MUNICIPALITY.id }, function (data) {
|
||||
if (data.error) {
|
||||
console.error('Load Error:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
contributionsData = data.features || [];
|
||||
|
||||
// Remove existing Layer if present
|
||||
if (contributionsLayer) {
|
||||
map.removeLayer(contributionsLayer);
|
||||
layerControl.removeLayer(contributionsLayer);
|
||||
}
|
||||
|
||||
// Create new GeoJSON Layer
|
||||
contributionsLayer = L.geoJSON(data, {
|
||||
pointToLayer: stylePoint,
|
||||
style: styleLinePolygon,
|
||||
onEachFeature: bindFeaturePopup
|
||||
}).addTo(map);
|
||||
|
||||
layerControl.addOverlay(contributionsLayer, 'Beiträge');
|
||||
|
||||
// Update Sidebar List and Statistics
|
||||
updateContributionsList();
|
||||
updateStatistics();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 8: Feature Styling by Category
|
||||
// =====================================================================
|
||||
|
||||
// Style for Point Features (CircleMarkers)
|
||||
function stylePoint(feature, latlng) {
|
||||
var cat = CATEGORIES[feature.properties.category] || CATEGORIES.other;
|
||||
|
||||
return L.circleMarker(latlng, {
|
||||
radius: 8,
|
||||
color: '#ffffff',
|
||||
weight: 2,
|
||||
fillColor: cat.color,
|
||||
fillOpacity: 0.9
|
||||
});
|
||||
}
|
||||
|
||||
// Style for Line and Polygon Features
|
||||
function styleLinePolygon(feature) {
|
||||
var cat = CATEGORIES[feature.properties.category] || CATEGORIES.other;
|
||||
|
||||
return {
|
||||
color: cat.color,
|
||||
weight: 3,
|
||||
opacity: 0.8,
|
||||
fillColor: cat.color,
|
||||
fillOpacity: 0.25
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 9: Feature Popups (Read, Vote, Edit, Delete)
|
||||
// =====================================================================
|
||||
|
||||
function bindFeaturePopup(feature, layer) {
|
||||
var props = feature.properties;
|
||||
var cat = CATEGORIES[props.category] || CATEGORIES.other;
|
||||
|
||||
// Format Date
|
||||
var date = new Date(props.created_at);
|
||||
var dateStr = date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
});
|
||||
|
||||
// Build Popup HTML
|
||||
var html = '' +
|
||||
'<div class="popup-detail">' +
|
||||
'<span class="popup-detail-category">' + cat.icon + ' ' + cat.label + '</span>' +
|
||||
'<div class="popup-detail-title">' + escapeHtml(props.title) + '</div>' +
|
||||
(props.description ? '<div class="popup-detail-description">' + escapeHtml(props.description) + '</div>' : '') +
|
||||
'<div class="popup-detail-meta">' +
|
||||
'<i class="fa-solid fa-user"></i> ' + escapeHtml(props.author_name) +
|
||||
' · <i class="fa-solid fa-calendar"></i> ' + dateStr +
|
||||
'</div>' +
|
||||
'<div class="popup-detail-votes">' +
|
||||
'<button class="popup-vote-btn" onclick="voteContribution(' + props.contribution_id + ', \'like\')" title="Gefällt mir">' +
|
||||
'<i class="fa-solid fa-thumbs-up"></i> <span id="likes-' + props.contribution_id + '">' + props.likes_count + '</span>' +
|
||||
'</button>' +
|
||||
'<button class="popup-vote-btn" onclick="voteContribution(' + props.contribution_id + ', \'dislike\')" title="Gefällt mir nicht">' +
|
||||
'<i class="fa-solid fa-thumbs-down"></i> <span id="dislikes-' + props.contribution_id + '">' + props.dislikes_count + '</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="popup-detail-actions">' +
|
||||
'<button class="btn btn-primary" onclick="editContribution(' + props.contribution_id + ')"><i class="fa-solid fa-pen"></i> Bearbeiten</button>' +
|
||||
'<button class="btn btn-danger" onclick="deleteContribution(' + props.contribution_id + ')"><i class="fa-solid fa-trash"></i> Löschen</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
layer.bindPopup(html, { maxWidth: 320, minWidth: 240 });
|
||||
|
||||
// Tooltip on Hover
|
||||
layer.bindTooltip(cat.icon + ' ' + escapeHtml(props.title), {
|
||||
direction: 'top',
|
||||
offset: [0, -10]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 10: CRUD Operations
|
||||
// =====================================================================
|
||||
|
||||
// CREATE — Submit new Contribution from Modal
|
||||
function submitCreate() {
|
||||
var category = document.getElementById('create-category').value;
|
||||
var title = document.getElementById('create-title').value.trim();
|
||||
var description = document.getElementById('create-description').value.trim();
|
||||
var geom = document.getElementById('create-geom').value;
|
||||
var geomType = document.getElementById('create-geom-type').value;
|
||||
|
||||
// Validate
|
||||
if (!category) {
|
||||
Swal.fire('Kategorie fehlt', 'Bitte wählen Sie eine Kategorie aus.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!title) {
|
||||
Swal.fire('Titel fehlt', 'Bitte geben Sie einen Titel ein.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!geom) {
|
||||
Swal.fire('Geometrie fehlt', 'Bitte zeichnen Sie zuerst ein Objekt auf der Karte.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall({
|
||||
action: 'create',
|
||||
municipality_id: MUNICIPALITY.id,
|
||||
category: category,
|
||||
title: title,
|
||||
description: description,
|
||||
geom: geom,
|
||||
geom_type: geomType,
|
||||
author_name: currentUser
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
Swal.fire('Fehler', response.error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung veröffentlicht.', 'success');
|
||||
closeCreateModal();
|
||||
loadContributions();
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel Create — close Modal and clear Form
|
||||
function cancelCreate() {
|
||||
closeCreateModal();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
document.getElementById('create-category').value = '';
|
||||
document.getElementById('create-title').value = '';
|
||||
document.getElementById('create-description').value = '';
|
||||
document.getElementById('create-geom').value = '';
|
||||
document.getElementById('create-geom-type').value = '';
|
||||
drawnGeometry = null;
|
||||
drawnGeomType = null;
|
||||
}
|
||||
|
||||
// UPDATE — Edit an existing Contribution
|
||||
function editContribution(contributionId) {
|
||||
// Find Contribution in local Data
|
||||
var contribution = contributionsData.find(function (f) {
|
||||
return f.properties.contribution_id === contributionId;
|
||||
});
|
||||
|
||||
if (!contribution) return;
|
||||
|
||||
var props = contribution.properties;
|
||||
|
||||
Swal.fire({
|
||||
title: 'Beitrag bearbeiten',
|
||||
html:
|
||||
'<div style="text-align:left;">' +
|
||||
'<label style="font-weight:600;font-size:0.85rem;">Titel</label>' +
|
||||
'<input id="swal-title" class="swal2-input" value="' + escapeHtml(props.title) + '">' +
|
||||
'<label style="font-weight:600;font-size:0.85rem;">Beschreibung</label>' +
|
||||
'<textarea id="swal-description" class="swal2-textarea">' + escapeHtml(props.description || '') + '</textarea>' +
|
||||
'</div>',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Speichern',
|
||||
cancelButtonText: 'Abbrechen',
|
||||
confirmButtonColor: MUNICIPALITY.primaryColor,
|
||||
preConfirm: function () {
|
||||
return {
|
||||
title: document.getElementById('swal-title').value.trim(),
|
||||
description: document.getElementById('swal-description').value.trim()
|
||||
};
|
||||
}
|
||||
}).then(function (result) {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
apiCall({
|
||||
action: 'update',
|
||||
contribution_id: contributionId,
|
||||
title: result.value.title,
|
||||
description: result.value.description
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
Swal.fire('Fehler', response.error, 'error');
|
||||
return;
|
||||
}
|
||||
Swal.fire('Gespeichert!', 'Der Beitrag wurde aktualisiert.', 'success');
|
||||
loadContributions();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE — Delete a Contribution
|
||||
function deleteContribution(contributionId) {
|
||||
Swal.fire({
|
||||
title: 'Beitrag löschen?',
|
||||
text: 'Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Löschen',
|
||||
cancelButtonText: 'Abbrechen',
|
||||
confirmButtonColor: '#c62828'
|
||||
}).then(function (result) {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
apiCall({
|
||||
action: 'delete',
|
||||
contribution_id: contributionId
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
Swal.fire('Fehler', response.error, 'error');
|
||||
return;
|
||||
}
|
||||
Swal.fire('Gelöscht!', 'Der Beitrag wurde entfernt.', 'success');
|
||||
map.closePopup();
|
||||
loadContributions();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// VOTE — Like or Dislike a Contribution
|
||||
function voteContribution(contributionId, voteType) {
|
||||
if (!currentUser) {
|
||||
Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um abzustimmen.', 'info');
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall({
|
||||
action: 'vote',
|
||||
contribution_id: contributionId,
|
||||
voter_name: currentUser,
|
||||
vote_type: voteType
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
Swal.fire('Hinweis', response.error, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update Vote Counts in the Popup without reloading everything
|
||||
loadContributions();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 11: Sidebar — Contributions List
|
||||
// =====================================================================
|
||||
|
||||
function updateContributionsList() {
|
||||
var container = document.getElementById('contributions-list');
|
||||
var searchInput = document.getElementById('list-search-input');
|
||||
var searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
||||
|
||||
// Filter by active Categories and Search Term
|
||||
var filtered = contributionsData.filter(function (f) {
|
||||
var props = f.properties;
|
||||
var matchesCategory = activeFilters.indexOf(props.category) !== -1;
|
||||
var matchesSearch = !searchTerm ||
|
||||
props.title.toLowerCase().indexOf(searchTerm) !== -1 ||
|
||||
(props.description && props.description.toLowerCase().indexOf(searchTerm) !== -1) ||
|
||||
props.author_name.toLowerCase().indexOf(searchTerm) !== -1;
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
// Sort by Date (newest first)
|
||||
filtered.sort(function (a, b) {
|
||||
return new Date(b.properties.created_at) - new Date(a.properties.created_at);
|
||||
});
|
||||
|
||||
// Build HTML
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:#999;padding:20px;">Keine Beiträge gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
filtered.forEach(function (f) {
|
||||
var props = f.properties;
|
||||
var cat = CATEGORIES[props.category] || CATEGORIES.other;
|
||||
var date = new Date(props.created_at).toLocaleDateString('de-DE');
|
||||
|
||||
html += '' +
|
||||
'<div class="contribution-card" onclick="flyToContribution(' + props.contribution_id + ')">' +
|
||||
'<div class="contribution-card-header">' +
|
||||
'<span class="contribution-card-category">' + cat.icon + ' ' + cat.label + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="contribution-card-title">' + escapeHtml(props.title) + '</div>' +
|
||||
'<div class="contribution-card-meta">' +
|
||||
'<span>' + escapeHtml(props.author_name) + ' · ' + date + '</span>' +
|
||||
'<span class="contribution-card-votes">' +
|
||||
'<span title="Likes"><i class="fa-solid fa-thumbs-up"></i> ' + props.likes_count + '</span>' +
|
||||
'<span title="Dislikes"><i class="fa-solid fa-thumbs-down"></i> ' + props.dislikes_count + '</span>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Fly to a Contribution on the Map and open its Popup
|
||||
function flyToContribution(contributionId) {
|
||||
if (!contributionsLayer) return;
|
||||
|
||||
contributionsLayer.eachLayer(function (layer) {
|
||||
if (layer.feature && layer.feature.properties.contribution_id === contributionId) {
|
||||
// Zoom to Feature
|
||||
if (layer.getLatLng) {
|
||||
// Point Feature
|
||||
map.flyTo(layer.getLatLng(), 17);
|
||||
} else if (layer.getBounds) {
|
||||
// Line or Polygon Feature
|
||||
map.flyToBounds(layer.getBounds(), { maxZoom: 17 });
|
||||
}
|
||||
// Open Popup
|
||||
layer.openPopup();
|
||||
// Close Sidebar on Mobile
|
||||
if (window.innerWidth < 769) {
|
||||
sidebar.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search Input Event Listener
|
||||
document.getElementById('list-search-input').addEventListener('input', function () {
|
||||
updateContributionsList();
|
||||
});
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 12: Sidebar — Category Filter and Statistics
|
||||
// =====================================================================
|
||||
|
||||
// Build Category Filter Checkboxes
|
||||
function buildCategoryFilter() {
|
||||
var container = document.getElementById('category-filter');
|
||||
var html = '';
|
||||
|
||||
for (var key in CATEGORIES) {
|
||||
var cat = CATEGORIES[key];
|
||||
var checked = activeFilters.indexOf(key) !== -1 ? 'checked' : '';
|
||||
|
||||
html += '' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;">' +
|
||||
'<input type="checkbox" value="' + key + '" ' + checked + ' onchange="toggleCategoryFilter(this)">' +
|
||||
'<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:' + cat.color + ';"></span>' +
|
||||
'<span>' + cat.icon + ' ' + cat.label + '</span>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Toggle a Category Filter on/off
|
||||
function toggleCategoryFilter(checkbox) {
|
||||
var category = checkbox.value;
|
||||
|
||||
if (checkbox.checked) {
|
||||
if (activeFilters.indexOf(category) === -1) {
|
||||
activeFilters.push(category);
|
||||
}
|
||||
} else {
|
||||
activeFilters = activeFilters.filter(function (c) { return c !== category; });
|
||||
}
|
||||
|
||||
// Re-filter the Map Layer
|
||||
if (contributionsLayer) {
|
||||
contributionsLayer.eachLayer(function (layer) {
|
||||
if (layer.feature) {
|
||||
var cat = layer.feature.properties.category;
|
||||
if (activeFilters.indexOf(cat) !== -1) {
|
||||
layer.setStyle({ opacity: 1, fillOpacity: layer.feature.geometry.type === 'Point' ? 0.9 : 0.25 });
|
||||
if (layer.setRadius) layer.setRadius(8);
|
||||
} else {
|
||||
layer.setStyle({ opacity: 0, fillOpacity: 0 });
|
||||
if (layer.setRadius) layer.setRadius(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update List
|
||||
updateContributionsList();
|
||||
}
|
||||
|
||||
// Update Statistics in Home Tab
|
||||
function updateStatistics() {
|
||||
var container = document.getElementById('stats-container');
|
||||
var total = contributionsData.length;
|
||||
|
||||
// Count per Category
|
||||
var counts = {};
|
||||
contributionsData.forEach(function (f) {
|
||||
var cat = f.properties.category;
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
});
|
||||
|
||||
var html = '<p style="font-size:0.9rem;"><strong>' + total + '</strong> Beiträge insgesamt</p>';
|
||||
|
||||
for (var key in CATEGORIES) {
|
||||
var cat = CATEGORIES[key];
|
||||
var count = counts[key] || 0;
|
||||
if (count > 0) {
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.85rem;">' +
|
||||
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + cat.color + ';"></span>' +
|
||||
cat.label + ': ' + count +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 13: Modals — Welcome, Login, Info, Privacy, Imprint
|
||||
// =====================================================================
|
||||
|
||||
// Welcome Modal — show on first Visit
|
||||
function checkWelcomeModal() {
|
||||
var hasVisited = localStorage.getItem('webgis_welcomed');
|
||||
if (!hasVisited) {
|
||||
document.getElementById('welcome-modal').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeWelcomeAndShowLogin() {
|
||||
localStorage.setItem('webgis_welcomed', 'true');
|
||||
document.getElementById('welcome-modal').style.display = 'none';
|
||||
showLoginModal();
|
||||
}
|
||||
|
||||
// Login Modal
|
||||
function showLoginModal() {
|
||||
document.getElementById('login-modal').style.display = 'flex';
|
||||
document.getElementById('user-name-input').value = currentUser;
|
||||
document.getElementById('user-name-input').focus();
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
var name = document.getElementById('user-name-input').value.trim();
|
||||
if (!name) {
|
||||
Swal.fire('Name eingeben', 'Bitte geben Sie Ihren Namen ein.', 'warning');
|
||||
return;
|
||||
}
|
||||
currentUser = name;
|
||||
sessionStorage.setItem('webgis_user', currentUser);
|
||||
document.getElementById('login-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function skipLogin() {
|
||||
document.getElementById('login-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Info Modal
|
||||
function showInfoModal() {
|
||||
Swal.fire({
|
||||
title: 'Über das Portal',
|
||||
html: '<p style="text-align:left;line-height:1.6;">Das Bürgerbeteiligungsportal ermöglicht es ' +
|
||||
'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' +
|
||||
'<strong>' + MUNICIPALITY.name + '</strong> mitzuwirken.</p>' +
|
||||
'<p style="text-align:left;line-height:1.6;">Tragen Sie Hinweise, Ideen und Verbesserungsvorschläge ' +
|
||||
'direkt auf der Karte ein.</p>',
|
||||
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||
});
|
||||
}
|
||||
|
||||
// Privacy Modal
|
||||
function showPrivacyModal() {
|
||||
Swal.fire({
|
||||
title: 'Datenschutz',
|
||||
html: '<p style="text-align:left;line-height:1.6;">Dieses Portal speichert die von Ihnen ' +
|
||||
'eingegebenen Daten (Name, Beiträge, Bewertungen) zur Durchführung der Bürgerbeteiligung.</p>' +
|
||||
'<p style="text-align:left;line-height:1.6;">Ihre Daten werden nicht an Dritte weitergegeben. ' +
|
||||
'Details entnehmen Sie bitte der vollständigen Datenschutzerklärung der Stadt ' +
|
||||
MUNICIPALITY.name + '.</p>',
|
||||
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||
});
|
||||
}
|
||||
|
||||
// Imprint Modal
|
||||
function showImprintModal() {
|
||||
Swal.fire({
|
||||
title: 'Impressum',
|
||||
html: '<p style="text-align:left;line-height:1.6;">Stadt ' + MUNICIPALITY.name + '</p>' +
|
||||
'<p style="text-align:left;line-height:1.6;color:#777;">Die vollständigen Angaben gemäß § 5 TMG ' +
|
||||
'werden hier ergänzt, sobald das Portal in den Produktivbetrieb geht.</p>',
|
||||
confirmButtonColor: MUNICIPALITY.primaryColor
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 14: Mobile Navigation
|
||||
// =====================================================================
|
||||
|
||||
function toggleMobileNav() {
|
||||
var nav = document.querySelector('.header-nav');
|
||||
nav.classList.toggle('open');
|
||||
}
|
||||
|
||||
// Close Mobile Nav when clicking outside
|
||||
document.addEventListener('click', function (e) {
|
||||
var nav = document.querySelector('.header-nav');
|
||||
var toggle = document.querySelector('.header-menu-toggle');
|
||||
|
||||
if (nav.classList.contains('open') && !nav.contains(e.target) && !toggle.contains(e.target)) {
|
||||
nav.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Close Modals on Escape Key
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.getElementById('welcome-modal').style.display = 'none';
|
||||
document.getElementById('login-modal').style.display = 'none';
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 15: Utility Functions
|
||||
// =====================================================================
|
||||
|
||||
// Escape HTML to prevent XSS in Popups and Lists
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(text));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// Block 16: Application Startup
|
||||
// =====================================================================
|
||||
|
||||
// Initialize Category Filter in Sidebar
|
||||
buildCategoryFilter();
|
||||
|
||||
// Load Contributions from API
|
||||
loadContributions();
|
||||
|
||||
// Show Welcome Modal on first Visit
|
||||
checkWelcomeModal();
|
||||
627
public/styles.css
Normal file
627
public/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
129
scripts/backup.sh
Normal file
129
scripts/backup.sh
Normal 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
21
README.md
Normal 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.
|
||||
Reference in New Issue
Block a user