diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6daedb4 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Example Environment Configfile +POSTGRES_HOSTNAME=postgres_host +POSTGRES_PORT=postgres_port +POSTGRES_DB=postgres_database +POSTGRES_USER=postgres_user +POSTGRES_PASSWORD= +ADMIN_PASSWORD= \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..30def97 --- /dev/null +++ b/.gitattributes @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e89a32f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.vscode/ +*.log +scripts \ No newline at end of file diff --git a/EXTENSION.md b/EXTENSION.md new file mode 100644 index 0000000..c24a72b --- /dev/null +++ b/EXTENSION.md @@ -0,0 +1,78 @@ +## Neue Ideenkarte anlegen +1. DNS record `````` A 195.59.32.237 600s +2. Nginx Weiterleitung in ```default.conf```: + +``` +server { + listen 443 ssl; + server_name .endex-geodaten.de; + + ssl_certificate /etc/letsencrypt/live/endex-geodaten.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/endex-geodaten.de/privkey.pem; + + root /var/www/webgis-/public; + index index.php index.html; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass webgis--php:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} +``` + +3. Docker container für UI +``` + webgis--php: + build: php-docker/ + container_name: webgis--php + volumes: + - ./webgis-:/var/www/webgis- + networks: + - frontend + - webgis--nw +``` + +und Datenbank anlegen. + +``` + webgis-db: + image: postgis/postgis:15-3.3 + container_name: webgis--db + restart: always + ports: + - "127.0.0.1:543:5432" # inside the container always 5432 + environment: + - POSTGRES_USER=${WEBGIS_DB_USER} # maybe go back to default username + - POSTGRES_PASSWORD=${WEBGIS_DB_PW} # must be secure and unique + - POSTGRES_DB=${WEBGIS_DB_NAME} #same as container name + volumes: + - ./webgis--data:/var/lib/postgresql/data + networks: + - webgis--nw +``` + +4. nginx Volume für neue Stadt in ```docker-compose.yml``` anlegen +``` +./webgis-:/var/www/webgis- +``` + + +5. Frontend source code nach ```webgis-``` klonen +``` +git submodule add -b https://git.endex-geodaten.de/lukas.uptmoor/webgis-.git +``` + +Jede Kommune sollte ein eigenes Repo kriegen, da Features am Anfang variieren. + + +6. Mit der Datenbank verbinden über SSH-Tunnel +``` +ssh -L 5433:localhost:543 root@endex-geodaten.de +``` +und Datenbank für Anwendung vorbereiten. \ No newline at end of file diff --git a/index.php b/index.php deleted file mode 100644 index 66f7713..0000000 --- a/index.php +++ /dev/null @@ -1,56 +0,0 @@ - PDO::ERRMODE_EXCEPTION]); - - if ($pdo) { - echo "

✅ Connected to PostGIS!

"; - - // Check PostGIS version - $query = $pdo->query("SELECT PostGIS_full_version();"); - $version = $query->fetch(); - echo "

PostGIS Version: " . $version[0] . "

"; - } -} catch (PDOException $e) { - echo "

❌ Connection Failed

"; - echo "

" . $e->getMessage() . "

"; -} -?> - - - - - - 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(); - // } -?> - diff --git a/legacy/delete_data.php b/legacy/delete_data.php new file mode 100644 index 0000000..8cc6d53 --- /dev/null +++ b/legacy/delete_data.php @@ -0,0 +1,48 @@ + 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(); + } + } + +?> \ No newline at end of file diff --git a/legacy/find_data.php b/legacy/find_data.php new file mode 100644 index 0000000..f9fce34 --- /dev/null +++ b/legacy/find_data.php @@ -0,0 +1,52 @@ + 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(); + + } + +?> \ No newline at end of file diff --git a/legacy/insert_data.php b/legacy/insert_data.php new file mode 100644 index 0000000..54742c8 --- /dev/null +++ b/legacy/insert_data.php @@ -0,0 +1,73 @@ + 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)))"); + } + } + + + + + + + ?> \ No newline at end of file diff --git a/legacy/load_data.php b/legacy/load_data.php new file mode 100644 index 0000000..2d8b17d --- /dev/null +++ b/legacy/load_data.php @@ -0,0 +1,63 @@ + 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(); + + } + +?> \ No newline at end of file diff --git a/legacy/test.html b/legacy/test.html new file mode 100644 index 0000000..8a65c7b --- /dev/null +++ b/legacy/test.html @@ -0,0 +1,97 @@ + + + + + + Document + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/legacy/update_data.php b/legacy/update_data.php new file mode 100644 index 0000000..bfe7884 --- /dev/null +++ b/legacy/update_data.php @@ -0,0 +1,98 @@ + 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(); + } + } + +?> \ No newline at end of file diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..54a7574 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -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 +-- ===================================================================== \ No newline at end of file diff --git a/migrations/002_add_votes_index.sql b/migrations/002_add_votes_index.sql new file mode 100644 index 0000000..e1da274 --- /dev/null +++ b/migrations/002_add_votes_index.sql @@ -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 +-- ===================================================================== \ No newline at end of file diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..b851ead --- /dev/null +++ b/public/admin.css @@ -0,0 +1,281 @@ +/* ===================================================================== + Moderation Page Styles + ===================================================================== */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: #f4f5f7; + color: #1a1a2e; + font-size: 15px; +} + +/* ----------------------------------------------------------------- + Header + ----------------------------------------------------------------- */ +.admin-header { + background: var(--color-primary); + color: white; + padding: 16px 24px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.admin-header h1 { font-size: 1.2rem; } + +.admin-header a { + color: white; + text-decoration: none; + opacity: 0.8; + font-size: 0.85rem; +} + +.admin-header a:hover { opacity: 1; } + +.admin-nav { + display: flex; + gap: 16px; + align-items: center; +} + +/* ----------------------------------------------------------------- + Container + ----------------------------------------------------------------- */ +.admin-container { + max-width: 900px; + margin: 24px auto; + padding: 0 16px; +} + +/* ----------------------------------------------------------------- + Statistics Cards + ----------------------------------------------------------------- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 32px; +} + +.stat-card { + background: white; + border-radius: 8px; + padding: 16px; + text-align: center; + border: 1px solid #e0e0e0; +} + +.stat-card .stat-number { + font-size: 1.8rem; + font-weight: 700; + color: var(--color-primary); +} + +.stat-card .stat-label { + font-size: 0.8rem; + color: #5a5a7a; + margin-top: 4px; +} + +/* ----------------------------------------------------------------- + Section Headers + ----------------------------------------------------------------- */ +h2 { + font-size: 1.1rem; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 2px solid var(--color-primary); +} + +.section { margin-bottom: 40px; } + +/* ----------------------------------------------------------------- + Contribution Rows + ----------------------------------------------------------------- */ +.contribution-row { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.contribution-info { flex: 1; } + +.contribution-info .title { + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 4px; +} + +.contribution-info .meta { + font-size: 0.8rem; + color: #5a5a7a; + margin-bottom: 4px; +} + +.contribution-info .description { + font-size: 0.85rem; + color: #5a5a7a; + line-height: 1.4; +} + +/* ----------------------------------------------------------------- + Badges + ----------------------------------------------------------------- */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-pending { background: #fff3cd; color: #856404; } +.badge-approved { background: #d4edda; color: #155724; } +.badge-rejected { background: #f8d7da; color: #721c24; } +.badge-point { background: #e3f2fd; color: #1565c0; } +.badge-line { background: #f3e5f5; color: #6a1b9a; } +.badge-polygon { background: #e8f5e9; color: #2e7d32; } + +/* ----------------------------------------------------------------- + Action Buttons + ----------------------------------------------------------------- */ +.action-buttons { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.btn-approve { background: #2e7d32; color: white; } +.btn-approve:hover { background: #1b5e20; } + +.btn-reject { background: #c62828; color: white; } +.btn-reject:hover { background: #b71c1c; } + +/* ----------------------------------------------------------------- + Empty State + ----------------------------------------------------------------- */ +.empty-state { + text-align: center; + padding: 40px; + color: #999; + font-size: 0.9rem; +} + +/* ----------------------------------------------------------------- + Login Page + ----------------------------------------------------------------- */ +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.login-box { + background: white; + border-radius: 12px; + padding: 32px; + max-width: 380px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.login-box h1 { + font-size: 1.3rem; + margin-bottom: 8px; +} + +.login-box p { + font-size: 0.85rem; + color: #5a5a7a; + margin-bottom: 20px; +} + +.login-box input[type="password"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #e0e0e0; + border-radius: 6px; + font-size: 0.9rem; + margin-bottom: 12px; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.login-box input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(0, 55, 109, 0.1); +} + +.login-box button { + width: 100%; + padding: 10px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.login-box button:hover { filter: brightness(1.15); } + +.error { + color: #c62828; + font-size: 0.85rem; + margin-bottom: 12px; +} + +.back-link { + margin-top: 16px; + font-size: 0.8rem; +} + +.back-link a { color: #5a5a7a; } + +/* ----------------------------------------------------------------- + Mobile Responsive + ----------------------------------------------------------------- */ +@media (max-width: 768px) { + .contribution-row { + flex-direction: column; + } + + .action-buttons { + width: 100%; + } + + .action-buttons form { + flex: 1; + } + + .action-buttons .btn { + width: 100%; + justify-content: center; + } +} \ No newline at end of file diff --git a/public/admin.php b/public/admin.php new file mode 100644 index 0000000..002447c --- /dev/null +++ b/public/admin.php @@ -0,0 +1,254 @@ +prepare("SELECT * FROM municipalities WHERE slug = :slug"); +$stmt->execute([':slug' => 'lohne']); +$municipality = $stmt->fetch(); + +// Show Login Page if not authenticated +if ($page === 'login' || !is_admin()) { + show_login_page($municipality, $login_error ?? null); + exit; +} + +// ----------------------------------------------------------------- +// Handle Moderation Actions (Approve / Reject) +// ----------------------------------------------------------------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mod_action'])) { + $contribution_id = $_POST['contribution_id'] ?? ''; + $mod_action = $_POST['mod_action']; + + if ($contribution_id && in_array($mod_action, ['approved', 'rejected'])) { + $stmt = $pdo->prepare("UPDATE contributions SET status = :status WHERE contribution_id = :id"); + $stmt->execute([':status' => $mod_action, ':id' => $contribution_id]); + } + + // Redirects to prevent Form Resubmission on Refresh + header('Location: admin.php'); + exit; +} + +// ----------------------------------------------------------------- +// Load Contributions Data +// ----------------------------------------------------------------- + +// Pending Contributions +$stmt = $pdo->prepare(" + SELECT contribution_id, title, category, description, author_name, geom_type, status, created_at + FROM contributions + WHERE municipality_id = :mid AND status = 'pending' + ORDER BY created_at DESC +"); +$stmt->execute([':mid' => $municipality['municipality_id']]); +$pending = $stmt->fetchAll(); + +// Recently moderated Contributions +$stmt = $pdo->prepare(" + SELECT contribution_id, title, category, description, author_name, geom_type, status, created_at, updated_at + FROM contributions + WHERE municipality_id = :mid AND status IN ('approved', 'rejected') + ORDER BY updated_at DESC + LIMIT 20 +"); +$stmt->execute([':mid' => $municipality['municipality_id']]); +$moderated = $stmt->fetchAll(); + +// Statistics +$stmt = $pdo->prepare(" + SELECT status, COUNT(*) as count + FROM contributions + WHERE municipality_id = :mid + GROUP BY status +"); +$stmt->execute([':mid' => $municipality['municipality_id']]); +$stats_rows = $stmt->fetchAll(); +$stats = []; +foreach ($stats_rows as $row) { + $stats[$row['status']] = $row['count']; +} + +// ----------------------------------------------------------------- +// Render Main Page +// ----------------------------------------------------------------- +?> + + + + + + Moderation — <?= htmlspecialchars($municipality['name']) ?> + + + + + + +
+

Moderation —

+ +
+ +
+ +
+
+
+
Ausstehend
+
+
+
+
Freigegeben
+
+
+
+
Abgelehnt
+
+
+
+
Gesamt
+
+
+ +
+

Ausstehende Beiträge ()

+ + +
+ + Keine ausstehenden Beiträge. +
+ + +
+
+
+
+ + ausstehend + · + · + · +
+ +
+ +
+
+
+ + + +
+
+ + + +
+
+
+ + +
+ +
+

Kürzlich moderiert

+ + +
Noch keine moderierten Beiträge.
+ + +
+
+
+
+ + + · + · + · +
+
+
+ + +
+ +
+ + + + + + + + + + + Moderation — Anmeldung + + + + + + + + + \ No newline at end of file diff --git a/public/api/auth.php b/public/api/auth.php new file mode 100644 index 0000000..d38695f --- /dev/null +++ b/public/api/auth.php @@ -0,0 +1,41 @@ + $municipality_id]; + + // Optional: Filters by Status (Default: only approved) + $status = $input['status'] ?? 'approved'; + if ($status !== 'all') { + $sql .= " AND status = :status"; + $params[':status'] = $status; + } + + // 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 { + // Checks if Voter already voted on this Contribution + $stmt = $pdo->prepare(" + SELECT vote_id, vote_type FROM votes + WHERE contribution_id = :cid AND voter_name = :voter + "); + $stmt->execute([':cid' => $input['contribution_id'], ':voter' => $input['voter_name']]); + $existing = $stmt->fetch(); + + if ($existing) { + if ($existing['vote_type'] === $input['vote_type']) { + // Same Vote Type — Removes Vote + $stmt = $pdo->prepare("DELETE FROM votes WHERE vote_id = :vid"); + $stmt->execute([':vid' => $existing['vote_id']]); + json_response(['message' => 'Vote removed.', 'action' => 'removed']); + } else { + // Different Vote Type — Switches Vote + $stmt = $pdo->prepare("DELETE FROM votes WHERE vote_id = :vid"); + $stmt->execute([':vid' => $existing['vote_id']]); + + $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 changed.', 'action' => 'changed'], 200); + } + } else { + // No existing Vote — Inserts Vote + $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.', 'action' => 'created'], 201); + } + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } +} \ No newline at end of file diff --git a/public/api/db.php b/public/api/db.php new file mode 100644 index 0000000..e3b08d9 --- /dev/null +++ b/public/api/db.php @@ -0,0 +1,94 @@ + $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; +} \ No newline at end of file diff --git a/public/api/init.php b/public/api/init.php new file mode 100644 index 0000000..1519732 --- /dev/null +++ b/public/api/init.php @@ -0,0 +1,51 @@ + 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); + + + $pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + + + + + +// Creates Error Message +} catch(PDOException $e) { + echo "Error: ".$e->getMessage(); +} + +?> \ No newline at end of file diff --git a/public/assets/icon-municipality.png b/public/assets/icon-municipality.png new file mode 100644 index 0000000..4600986 Binary files /dev/null and b/public/assets/icon-municipality.png differ diff --git a/public/assets/logo-company.png b/public/assets/logo-company.png new file mode 100644 index 0000000..70257fe Binary files /dev/null and b/public/assets/logo-company.png differ diff --git a/public/assets/logo-municipality.png b/public/assets/logo-municipality.png new file mode 100644 index 0000000..4600986 Binary files /dev/null and b/public/assets/logo-municipality.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..91c8269 --- /dev/null +++ b/public/index.php @@ -0,0 +1,350 @@ +prepare("SELECT * FROM municipalities WHERE slug = :slug"); +$stmt->execute([':slug' => 'lohne']); +$municipality = $stmt->fetch(); + +if (!$municipality) { + http_response_code(404); + echo "

404 — Municipality not listed in Database.

"; + exit; +} + +?> + + + + + + Bürgerbeteiligungsportal <?= htmlspecialchars($municipality['name']) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Bürgerbeteiligung

+
+ + + + + + + + + +
+ + + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..a1c1613 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,948 @@ +// ===================================================================== +// WebGIS Citizen Participation Portal — Application Logic +// Initializes Leaflet Map, loads Contributions from the API, +// handles CRUD Workflow, and manages all UI Interactions. +// +// Depends on: MUNICIPALITY Object set in Main Page, Leaflet, Geoman, +// Sidebar, Geocoder, PolylineMeasure, Fullscreen, +// and SweetAlert2 Plugins. +// ===================================================================== + + +// ===================================================================== +// Block 1: Configuration and Application State +// ===================================================================== + +// API Endpoint as relative Path +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 +var drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman +var drawnGeomType = null; // Temporary Storage for Geometry Type +var userVotes = {}; // Tracks User Votes + +// ===================================================================== +// Block 2: Map Initialization +// ===================================================================== + +map = L.map('map', { + center: MUNICIPALITY.center, + zoom: MUNICIPALITY.zoom, + minZoom: 10, + maxBounds: [ + [MUNICIPALITY.center[0] - 0.25, MUNICIPALITY.center[1] - 0.25], + [MUNICIPALITY.center[0] + 0.25, MUNICIPALITY.center[1] + 0.25] + ], + maxBoundsViscosity: 0.8, + zoomControl: false, + attributionControl: true +}); + + +// ===================================================================== +// Block 3: Basemaps and Layer Control +// ===================================================================== + +// Basemap Tile Layers +var basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 20 +}); + +var basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© CARTO', + maxZoom: 20 +}); + +var basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: '© Esri', + maxZoom: 20 +}); + +// Set Default Basemap +basemapCartoDB.addTo(map); + +// Layer Control +var basemaps = { + 'OpenStreetMap': basemapOSM, + 'CartoDB (hell)': basemapCartoDB, + 'Satellit (Esri)': basemapSatellite, +}; + +var overlays = {}; // Populated later with Contribution Layers + +var layerControl = L.control.layers(basemaps, overlays, { + position: 'topright', + collapsed: true +}).addTo(map); + + +// ===================================================================== +// Block 4: Map Controls +// ===================================================================== + +// Zoom Control +L.control.zoom({ + position: 'topright' +}).addTo(map); + +// Scale Bar +L.control.scale({ + position: 'bottomright', + maxWidth: 200, + imperial: false +}).addTo(map); + +// Fullscreen Button +L.control.fullscreen({ + position: 'topright', + title: 'Vollbild', + titleCancel: 'Vollbild deaktivieren' +}).addTo(map); + +// Geocoder Address Search +L.Control.geocoder({ + position: 'topright', + placeholder: 'Adresse suchen...', + defaultMarkGeocode: true, + geocoder: L.Control.Geocoder.nominatim({ + geocodingQueryParams: { + countrycodes: 'de', + viewbox: (MUNICIPALITY.center[1] - 0.3) + ',' + (MUNICIPALITY.center[0] - 0.2) + ',' + + (MUNICIPALITY.center[1] + 0.3) + ',' + (MUNICIPALITY.center[0] + 0.2), + bounded: 0, + } + }) +}).addTo(map); + +// Polyline Measure Tool +L.control.polylineMeasure({ + position: 'topright', + unit: 'metres', + showBearings: false, + clearMeasurementsOnStop: false, + showClearControl: true +}).addTo(map); + +// Mouse Position Display +var MousePositionControl = L.Control.extend({ + options: { position: 'bottomright' }, + + onAdd: function () { + var container = L.DomUtil.create('div', 'mouse-position-display'); + container.innerHTML = 'Lat: , Lng: '; + + map.on('mousemove', function (e) { + container.innerHTML = 'Lat: ' + e.latlng.lat.toFixed(5) + ', Lng: ' + e.latlng.lng.toFixed(5); + }); + + return container; + } +}); + +new MousePositionControl().addTo(map); + +// GPS Location Button +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', 'gps-control-button', container); + button.href = '#'; + button.title = 'Mein Standort'; + button.innerHTML = ''; + + 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 gestatten Sie den Standortzugriff in Ihrem Browser.', 'warning'); +}); + + +// ===================================================================== +// Block 5: Sidebar Initialization +// ===================================================================== + +sidebar = L.control.sidebar({ + autopan: true, + closeButton: true, + container: 'sidebar', + position: 'left' +}).addTo(map); + + +// ===================================================================== +// Block 6: Geoman Drawing Tools and CRUD Trigger +// ===================================================================== + +map.pm.addControls({ + position: 'topright', + drawMarker: true, + drawPolyline: true, + drawPolygon: true, + drawCircleMarker: false, + drawCircle: false, + drawText: false, + drawRectangle: false, + editMode: false, + dragMode: false, + cutPolygon: false, + removalMode: false, + rotateMode: false +}); + +map.pm.setLang('de'); + +// Captures drawn Geometry and opens the Create Modal +map.on('pm:create', function (e) { + var geojson = e.layer.toGeoJSON().geometry; + + // Determines drawn Geometry Type and normalizes to simple Types + if (e.shape === 'Marker') { + drawnGeometry = { type: 'Point', coordinates: geojson.coordinates }; + drawnGeomType = 'point'; + } else if (e.shape === 'Line') { + drawnGeometry = { type: 'LineString', coordinates: geojson.coordinates }; + drawnGeomType = 'line'; + } else if (e.shape === 'Polygon') { + drawnGeometry = { type: 'Polygon', coordinates: geojson.coordinates }; + drawnGeomType = 'polygon'; + } else { + // removes unsupported Objects + map.removeLayer(e.layer); + return; + } + + // Removes the drawn Layer, which will be re-added after Moderation Confirmation + map.removeLayer(e.layer); + + // Checks if User is logged in + if (!currentUser) { + showLoginModal(); + return; + } + + // Populates hidden Fields and opens Create Modal + document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); + document.getElementById('create-geom-type').value = drawnGeomType; + document.getElementById('create-modal').style.display = 'flex'; +}); + + +// ===================================================================== +// Block 7: API Communication +// ===================================================================== + +// Generic API Call Function +function apiCall(data, callback) { + 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', 'Verbindung zum Server fehlgeschlagen.', 'error'); + }); +} + +// Loads all Contributions from API and displays Contributions 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 || []; + + // Removes existing Layer if present + if (contributionsLayer) { + map.removeLayer(contributionsLayer); + layerControl.removeLayer(contributionsLayer); + } + + // Creates new GeoJSON Layer + contributionsLayer = L.geoJSON(data, { + pointToLayer: stylePoint, + style: styleLinePolygon, + onEachFeature: bindFeaturePopup + }).addTo(map); + + layerControl.addOverlay(contributionsLayer, '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 for Read, Edit, Delete and Vote +// ===================================================================== + +function bindFeaturePopup(feature, layer) { + var props = feature.properties; + var cat = CATEGORIES[props.category] || CATEGORIES.other; + + // Formats Date + var date = new Date(props.created_at); + var dateStr = date.toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric' + }); + + // Builds Popup on Click + var html = '' + + ''; + + layer.bindPopup(html, { maxWidth: 320, minWidth: 240 }); + + // Builds Tooltip on Hover + layer.bindTooltip(cat.icon + ' ' + escapeHtml(props.title), { + direction: 'top', + offset: [0, -10] + }); +} + + +// ===================================================================== +// Block 10: CRUD Operations +// ===================================================================== + +// CREATE: Submits new Contributions 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; + + // Validates + 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 durch das Moderationsteam veröffentlicht.', 'success'); + closeCreateModal(); + loadContributions(); + }); +} + +// Cancels Create, closes Modal and clears Form +function cancelCreate() { + closeCreateModal(); +} + +function closeCreateModal() { + document.getElementById('create-modal').style.display = 'none'; + document.getElementById('create-category').value = ''; + document.getElementById('create-title').value = ''; + document.getElementById('create-description').value = ''; + document.getElementById('create-geom').value = ''; + document.getElementById('create-geom-type').value = ''; + drawnGeometry = null; + drawnGeomType = null; +} + +// UPDATE: Edits existing Contributions +function editContribution(contributionId) { + // Finds 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: + '
' + + '' + + '' + + '' + + '' + + '
', + showCancelButton: true, + confirmButtonText: 'Speichern', + cancelButtonText: 'Abbrechen', + confirmButtonColor: MUNICIPALITY.primaryColor, + preConfirm: function () { + return { + title: document.getElementById('swal-title').value.trim(), + description: document.getElementById('swal-description').value.trim() + }; + } + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'update', + contribution_id: contributionId, + title: result.value.title, + description: result.value.description + }, function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gespeichert!', 'Der Beitrag wurde aktualisiert.', 'success'); + loadContributions(); + }); + }); +} + +// DELETE: Deletes existing Contributions +function deleteContribution(contributionId) { + Swal.fire({ + title: 'Beitrag löschen?', + text: 'Aktion kann nicht rückgängig gemacht werden.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Löschen', + cancelButtonText: 'Abbrechen', + confirmButtonColor: '#c62828' + }).then(function (result) { + if (!result.isConfirmed) return; + + apiCall({ + action: 'delete', + contribution_id: contributionId + }, function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + Swal.fire('Gelöscht!', 'Der Beitrag wurde entfernt.', 'success'); + map.closePopup(); + loadContributions(); + }); + }); +} + +// VOTE: Like or Dislike existing Contributions +function voteContribution(contributionId, voteType) { + if (!currentUser) { + showLoginModal(); + return; + } + + apiCall({ + action: 'vote', + contribution_id: contributionId, + voter_name: currentUser, + vote_type: voteType + }, function (response) { + if (response.error) { + return; + } + + // Updates local Vote State + var likeBtn = document.getElementById('vote-like-' + contributionId); + var dislikeBtn = document.getElementById('vote-dislike-' + contributionId); + var likesSpan = document.getElementById('likes-' + contributionId); + var dislikesSpan = document.getElementById('dislikes-' + contributionId); + + if (response.action === 'created') { + // New Vote — Highlights Button and updates Count + userVotes[contributionId] = voteType; + if (voteType === 'like') { + likeBtn.classList.add('liked'); + likesSpan.textContent = parseInt(likesSpan.textContent) + 1; + } else { + dislikeBtn.classList.add('disliked'); + dislikesSpan.textContent = parseInt(dislikesSpan.textContent) + 1; + } + } else if (response.action === 'removed') { + // Vote removed — Removes Button Highlight and updates Count + delete userVotes[contributionId]; + if (voteType === 'like') { + likeBtn.classList.remove('liked'); + likesSpan.textContent = Math.max(0, parseInt(likesSpan.textContent) - 1); + } else { + dislikeBtn.classList.remove('disliked'); + dislikesSpan.textContent = Math.max(0, parseInt(dislikesSpan.textContent) - 1); + } + } else if (response.action === 'changed') { + // Vote changed — Switches Highlights and updates both Counts + userVotes[contributionId] = voteType; + if (voteType === 'like') { + likeBtn.classList.add('liked'); + dislikeBtn.classList.remove('disliked'); + likesSpan.textContent = parseInt(likesSpan.textContent) + 1; + dislikesSpan.textContent = Math.max(0, parseInt(dislikesSpan.textContent) - 1); + } else { + dislikeBtn.classList.add('disliked'); + likeBtn.classList.remove('liked'); + dislikesSpan.textContent = parseInt(dislikesSpan.textContent) + 1; + likesSpan.textContent = Math.max(0, parseInt(likesSpan.textContent) - 1); + } + } + }); +} + + +// ===================================================================== +// 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() : ''; + + // Filters by Categories and Search Term + var filtered = contributionsData.filter(function (f) { + var props = f.properties; + var matchesCategory = activeFilters.indexOf(props.category) !== -1; + var cat = CATEGORIES[props.category] || CATEGORIES.other; + var matchesSearch = !searchTerm || + props.title.toLowerCase().indexOf(searchTerm) !== -1 || + (props.description && props.description.toLowerCase().indexOf(searchTerm) !== -1) || + props.author_name.toLowerCase().indexOf(searchTerm) !== -1 || + cat.label.toLowerCase().indexOf(searchTerm) !== -1; + return matchesCategory && matchesSearch; + }); + + // Sorts by Date (newest first) + filtered.sort(function (a, b) { + return new Date(b.properties.created_at) - new Date(a.properties.created_at); + }); + + // Builds HTML + if (filtered.length === 0) { + container.innerHTML = '

Keine Beiträge gefunden.

'; + 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 += '' + + '
' + + '
' + + '' + cat.icon + ' ' + cat.label + '' + + '
' + + '
' + escapeHtml(props.title) + '
' + + '
' + + '' + escapeHtml(props.author_name) + ' · ' + date + '' + + '' + + ' ' + props.likes_count + '' + + ' ' + props.dislikes_count + '' + + '' + + '
' + + '
'; + }); + + container.innerHTML = html; +} + +// Flies to a Contribution on the Map and open Popup +function flyToContribution(contributionId) { + if (!contributionsLayer) return; + + contributionsLayer.eachLayer(function (layer) { + if (layer.feature && layer.feature.properties.contribution_id === contributionId) { + // Zooms to Feature + if (layer.getLatLng) { + // Point Feature + map.flyTo(layer.getLatLng(), 17); + } else if (layer.getBounds) { + // Line or Polygon Feature + map.flyToBounds(layer.getBounds(), { maxZoom: 17 }); + } + // Opens Popup + layer.openPopup(); + // Closes Sidebar on Mobile + if (window.innerWidth < 769) { + sidebar.close(); + } + } + }); +} + +// Search Input Event Listener +document.getElementById('list-search-input').addEventListener('input', function () { + updateContributionsList(); +}); + + +// ===================================================================== +// Block 12: Sidebar Category Filter and Statistics +// ===================================================================== + +// Builds Category Filter Checkboxes +function buildCategoryFilter() { + 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 += '' + + ''; + } + + container.innerHTML = html; +} + +// Toggles a Category Filter on or 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; }); + } + + // Refilters 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); + layer.options.interactive = true; + } else { + layer.setStyle({ opacity: 0, fillOpacity: 0 }); + if (layer.setRadius) layer.setRadius(0); + layer.options.interactive = false; + layer.closePopup(); + layer.closeTooltip(); + } + } + }); + } + + // Updates List + updateContributionsList(); +} + +// Updates Statistics in Home Tab +function updateStatistics() { + var container = document.getElementById('stats-container'); + var total = contributionsData.length; + + // Counts per Category + var counts = {}; + contributionsData.forEach(function (f) { + var cat = f.properties.category; + counts[cat] = (counts[cat] || 0) + 1; + }); + + var html = '

' + total + ' Beiträge insgesamt

'; + + for (var key in CATEGORIES) { + var cat = CATEGORIES[key]; + var count = counts[key] || 0; + if (count > 0) { + html += '
' + + '' + + cat.label + ': ' + count + + '
'; + } + } + + container.innerHTML = html; +} + + +// ===================================================================== +// Block 13: Modals — Welcome, Login, Info, Privacy, Imprint +// ===================================================================== + +// Welcome Modal shows on new Visits +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 shows new Session +function showLoginModal() { + document.getElementById('login-modal').style.display = 'flex'; + document.getElementById('user-name-input').value = currentUser; + document.getElementById('user-name-input').focus(); +} + +function submitLogin() { + 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'; + + // Open Create Modal if Geometry is pending + if (drawnGeometry) { + document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); + document.getElementById('create-geom-type').value = drawnGeomType; + document.getElementById('create-modal').style.display = 'flex'; + } +} + +function skipLogin() { + document.getElementById('login-modal').style.display = 'none'; +} + +// Info Modal +function showInfoModal() { + Swal.fire({ + title: 'Informationen', + html: '

Das Bürgerbeteiligungsportal gestattet ' + + 'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' + + '' + MUNICIPALITY.name + ' mitzuwirken.

' + + '

Bitte tragen Sie Hinweise, Anregungen und Vorschläge ' + + 'mithilfe der Zeichenwerkzeuge auf der Karte ein.

', + confirmButtonColor: MUNICIPALITY.primaryColor + }); +} + +// Privacy Modal +function showPrivacyModal() { + Swal.fire({ + title: 'Datenschutz', + html: '

Das Bürgerbeteiligungsportal speichert die von Ihnen ' + + 'hinterlegten Daten zur Durchführung der Bürgerbeteiligung.

' + + '

Ihre Daten werden nicht an Dritte weitergegeben. ' + + 'Details entnehmen Sie bitte der vollständigen Datenschutzerklärung von ' + + MUNICIPALITY.name + '.

', + confirmButtonColor: MUNICIPALITY.primaryColor + }); +} + +// Imprint Modal +function showImprintModal() { + Swal.fire({ + title: 'Impressum', + html: '

Stadt ' + MUNICIPALITY.name + '

' + + '

Die vollständigen Angaben ' + + 'werden hier hinzugefügt, sobald das Portal in den Produktivbetrieb geht.

', + confirmButtonColor: MUNICIPALITY.primaryColor + }); +} + + +// ===================================================================== +// Block 14: Mobile Navigation +// ===================================================================== + +function toggleMobileNav() { + var nav = document.querySelector('.header-nav'); + nav.classList.toggle('open'); +} + +// Closes 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'); + } +}); + +// Closes Modals on Escape Key +document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + document.getElementById('welcome-modal').style.display = 'none'; + document.getElementById('login-modal').style.display = 'none'; + document.getElementById('create-modal').style.display = 'none'; + } +}); + + +// ===================================================================== +// Block 15: Utility Functions +// ===================================================================== + +// Escapes HTML to prevent Cross-Site Scripting (XSS) in Popups and Lists +function escapeHtml(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; +} + + +// ===================================================================== +// Block 16: Application Startup +// ===================================================================== + +// Populates Category Dropdown in Create Modal from Categories Object +function buildCategoryDropdown() { + var select = document.getElementById('create-category'); + for (var key in CATEGORIES) { + var cat = CATEGORIES[key]; + var option = document.createElement('option'); + option.value = key; + option.textContent = cat.icon + ' ' + cat.label; + select.appendChild(option); + } +} + +// Populates Category Dropdown +buildCategoryDropdown(); + +// Initializes Category Filter in Sidebar +buildCategoryFilter(); + +// Loads Contributions from API +loadContributions(); + +// Shows Welcome Modal on first Visit +checkWelcomeModal(); \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..59b5b08 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,678 @@ +/* ===================================================================== + WebGIS Citizen Participation Portal — Styles + Mobile-First Layout with CSS Custom Properties for Municipality Theme + ===================================================================== */ + + +/* ----------------------------------------------------------------- + CSS Custom Properties Defaults — overridden per Municipality + ----------------------------------------------------------------- */ +:root { + /* Municipality Colors */ + --color-primary: #00376D; + --color-primary-light: #00376D22; + + /* 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); +} + +.nav-btn-admin { + text-decoration: none; + margin-left: var(--space-sm); + background: rgba(255, 255, 255, 0.05); + opacity: 0.6; + font-size: 0.8rem; +} + +.nav-btn-admin:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.2); +} + +.header-menu-toggle { + display: none; + border: none; + background: none; + color: white; + font-size: 1.4rem; + cursor: pointer; + padding: var(--space-sm); +} + + +/* ----------------------------------------------------------------- + Main and 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; +} + + +/* ----------------------------------------------------------------- + Mouse Position Display + ----------------------------------------------------------------- */ +.mouse-position-display { + background: rgba(255, 255, 255, 0.85); + padding: 2px 8px; + font-size: 0.75rem; + border-radius: 4px; +} + +@media (max-width: 768px) { + .mouse-position-display { + display: none; + } +} + + +/* ----------------------------------------------------------------- + GPS Control Button + ----------------------------------------------------------------- */ +.gps-control-button { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + font-size: 16px; +} + + +/* ----------------------------------------------------------------- + Sidebar Overrides + ----------------------------------------------------------------- */ +.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; +} + + +/* ----------------------------------------------------------------- + SweetAlert Font Override + ----------------------------------------------------------------- */ +.swal2-input, +.swal2-textarea { + font-family: var(--font-body) !important; +} + + +/* ----------------------------------------------------------------- + Mobile Responsive Overrides + ----------------------------------------------------------------- */ +@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 + ----------------------------------------------------------------- */ +@media (min-width: 769px) { + :root { + --map-side-padding: 8px; + } +} \ No newline at end of file diff --git a/readme.md b/readme.md deleted file mode 100644 index 4c56fa0..0000000 --- a/readme.md +++ /dev/null @@ -1 +0,0 @@ -obst diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..2348781 --- /dev/null +++ b/scripts/backup.sh @@ -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." \ No newline at end of file diff --git "a/\177README.md" "b/\177README.md" new file mode 100644 index 0000000..a62f6d5 --- /dev/null +++ "b/\177README.md" @@ -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. \ No newline at end of file