4 Commits

Author SHA1 Message Date
luptmoor
0f15e92d65 comment counter now refreshed for each deletion or addition of comment 2026-04-28 10:21:27 +02:00
luptmoor
942affd5e5 fix typo contribution_id 2026-04-28 10:08:49 +02:00
luptmoor
02ba53724e prepared comment counting 2026-04-28 10:04:51 +02:00
luptmoor
d0bba3d3f8 EXTENSION.md updated and completed 2026-04-28 09:20:54 +02:00
8 changed files with 202 additions and 585 deletions

View File

@@ -1,8 +1,47 @@
## Neue Ideenkarte anlegen # Neue Ideenkarte anlegen
1. DNS record ```<name>``` A 195.59.32.237 600s
2. Nginx Weiterleitung in ```default.conf```:
## Übersicht
| Variable | Bedeutung |
|---|---|
| `<name>` | Name der Kommune (z.B. `lohne`) |
| `<ID>` | Eindeutige Port-ID für die Datenbank (z.B. `4` → Port `5434`) |
| `<branch-name>` | Git-Branch des Frontend-Repos |
---
## Schritt 1 — DNS Record anlegen
Im DNS-Panel einen neuen A-Record anlegen:
| Feld | Wert |
|---|---|
| Name | `<name>` |
| Typ | `A` |
| Ziel | `195.59.32.237` |
| TTL | `600s` |
> ⚠️ DNS muss vollständig propagiert sein, bevor Certbot in Schritt 3 ausgeführt wird.
Propagation prüfen:
```bash
dig <name>.endex-geodaten.de
``` ```
---
## Schritt 2 — Nginx `default.conf` anpassen
### 2a — Subdomain in den Port-80-Block eintragen
```nginx
server_name endex-geodaten.de www.endex-geodaten.de git.endex-geodaten.de lohne.endex-geodaten.de <name>.endex-geodaten.de localhost;
```
### 2b — Neuen HTTPS-Server-Block hinzufügen
```nginx
# WEBGIS <NAME>
server { server {
listen 443 ssl; listen 443 ssl;
server_name <name>.endex-geodaten.de; server_name <name>.endex-geodaten.de;
@@ -18,7 +57,7 @@ server {
} }
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass webgis-<name>-php:9000; fastcgi_pass webgis-<name>-php:9000;
fastcgi_index index.php; fastcgi_index index.php;
include fastcgi_params; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
@@ -26,8 +65,34 @@ server {
} }
``` ```
3. Docker container für UI ---
## Schritt 3 — SSL-Zertifikat erneuern
Da kein Wildcard-Zertifikat verwendet wird, muss das Cert neu ausgestellt werden:
```bash
docker compose run --rm certbot certonly --webroot \
--webroot-path=/var/www/certbot \
-d endex-geodaten.de \
-d www.endex-geodaten.de \
-d git.endex-geodaten.de \
-d lohne.endex-geodaten.de \
-d <name>.endex-geodaten.de
``` ```
Nginx neu laden:
```bash
docker compose exec nginx nginx -s reload
```
---
## Schritt 4 — Docker Container in `docker-compose.yml` anlegen
### PHP/UI Container
```yaml
webgis-<name>-php: webgis-<name>-php:
build: php-docker/ build: php-docker/
container_name: webgis-<name>-php container_name: webgis-<name>-php
@@ -38,41 +103,81 @@ server {
- webgis-<name>-nw - webgis-<name>-nw
``` ```
und Datenbank anlegen. ### Datenbank Container
``` ```yaml
webgis-<name>db: webgis-<name>-db:
image: postgis/postgis:15-3.3 image: postgis/postgis:15-3.3
container_name: webgis-<name>-db container_name: webgis-<name>-db
restart: always restart: always
ports: ports:
- "127.0.0.1:543<ID>:5432" # inside the container always 5432 - "127.0.0.1:543<ID>:5432" # inside the container always 5432
environment: environment:
- POSTGRES_USER=${WEBGIS_DB_USER} # maybe go back to default username - POSTGRES_USER=${WEBGIS_<NAME>_DB_USER}
- POSTGRES_PASSWORD=${WEBGIS_DB_PW} # must be secure and unique - POSTGRES_PASSWORD=${WEBGIS_<NAME>_DB_PW}
- POSTGRES_DB=${WEBGIS_DB_NAME} #same as container name - POSTGRES_DB=${WEBGIS_<NAME>_DB_NAME}
volumes: volumes:
- ./webgis-<name>-data:/var/lib/postgresql/data - ./webgis-<name>-data:/var/lib/postgresql/data
networks: networks:
- webgis-<name>-nw - webgis-<name>-nw
``` ```
4. nginx Volume für neue Stadt in ```docker-compose.yml``` anlegen ### Netzwerk ergänzen
```
./webgis-<name>:/var/www/webgis-<name> Unter dem `networks:` Block am Ende der `docker-compose.yml`:
```yaml
networks:
webgis-<name>-nw:
driver: bridge
``` ```
---
5. Frontend source code nach ```webgis-<name>``` klonen ## Schritt 5 — Nginx Volume ergänzen
Beim nginx-Service in `docker-compose.yml` das neue Volume eintragen:
```yaml
volumes:
- ./webgis-<name>:/var/www/webgis-<name>
``` ```
---
## Schritt 6 — Frontend Source Code klonen
```bash
git submodule add -b <branch-name> https://git.endex-geodaten.de/lukas.uptmoor/webgis-<name>.git git submodule add -b <branch-name> https://git.endex-geodaten.de/lukas.uptmoor/webgis-<name>.git
``` ```
Jede Kommune sollte ein eigenes Repo kriegen, da Features am Anfang variieren. > Jede Kommune erhält ein eigenes Repo, da Features initial variieren können.
---
6. Mit der Datenbank verbinden über SSH-Tunnel ## Schritt 7 — Container starten
```bash
docker compose up -d webgis-<name>-php webgis-<name>-db
``` ```
Logs prüfen:
```bash
docker compose logs -f webgis-<name>-php
docker compose logs -f webgis-<name>-db
```
---
## Schritt 8 — Datenbank vorbereiten
SSH-Tunnel öffnen:
```bash
ssh -L 5433:localhost:543<ID> root@endex-geodaten.de ssh -L 5433:localhost:543<ID> root@endex-geodaten.de
``` ```
und Datenbank für Anwendung vorbereiten.
Strukturen laden:
```bash
docker exec -it webgis-<name>-db psql -U $POSTGRES_USER -d $POSTGRES_DB < migrations/001_initial_schema.sql
```

View File

@@ -31,5 +31,6 @@ CREATE INDEX idx_comments_browser ON comments(browser_id);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
ALTER TABLE contributions ALTER TABLE contributions
ADD COLUMN photo_path VARCHAR(255) DEFAULT NULL; ADD COLUMN photo_path VARCHAR(255) DEFAULT NULL;
ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN contributions.photo_path IS 'Relative Path to uploaded Photo. NULL = no Photo.'; COMMENT ON COLUMN contributions.photo_path IS 'Relative Path to uploaded Photo. NULL = no Photo.';

View File

@@ -1,14 +0,0 @@
-- =====================================================================
-- Migration 007: Adds Status Column to Comments for Moderation
-- =====================================================================
-- Adds Status Column with Default 'pending'
ALTER TABLE comments
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected'));
-- Index for fast Status Filtering
CREATE INDEX idx_comments_status ON comments(status);
-- Approves existing Comments
UPDATE comments SET status = 'approved';

View File

@@ -1,65 +0,0 @@
-- =====================================================================
-- Migration 008: Adds comment_count Column with automatic Trigger
-- Mirrors Pattern from likes_count and dislikes_count.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Block 1: Adds comment_count Column to Contributions
-- ---------------------------------------------------------------------
ALTER TABLE contributions
ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0;
-- ---------------------------------------------------------------------
-- Block 2: Backfills existing Comment Counts
-- ---------------------------------------------------------------------
UPDATE contributions c
SET comment_count = (
SELECT COUNT(*)
FROM comments cm
WHERE cm.contribution_id = c.contribution_id
AND cm.status = 'approved'
);
-- ---------------------------------------------------------------------
-- Block 3: Trigger Function to update comment_count
-- Fires on Status Change on comments. Only counts approved Comments
-- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_comment_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
UPDATE contributions
SET comment_count = (
SELECT COUNT(*) FROM comments
WHERE contribution_id = NEW.contribution_id
AND status = 'approved'
)
WHERE contribution_id = NEW.contribution_id;
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.contribution_id != NEW.contribution_id) THEN
UPDATE contributions
SET comment_count = (
SELECT COUNT(*) FROM comments
WHERE contribution_id = OLD.contribution_id
AND status = 'approved'
)
WHERE contribution_id = OLD.contribution_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------
-- Block 4: Attaches Trigger to comments Table
-- ---------------------------------------------------------------------
CREATE TRIGGER trigger_update_comment_count
AFTER INSERT OR DELETE OR UPDATE OF status
ON comments
FOR EACH ROW
EXECUTE FUNCTION update_comment_count();

View File

@@ -3,6 +3,12 @@
// Moderation Page // Moderation Page
// Lists Contributions for Review. Moderators can approve, reject, // Lists Contributions for Review. Moderators can approve, reject,
// edit and delete Contributions. Includes Map Preview and Filtering. // edit and delete Contributions. Includes Map Preview and Filtering.
//
// ToDo's:
// - Comment Moderation Tab
// - News Management Tab
// - User Management Tab
// - Analytics Tab
// ===================================================================== // =====================================================================
// Reads Environment Configfile // Reads Environment Configfile
@@ -51,7 +57,6 @@ $stmt = $pdo->prepare("SELECT * FROM municipalities WHERE slug = :slug");
$stmt->execute([':slug' => getenv('MUNICIPALITY_SLUG')]); $stmt->execute([':slug' => getenv('MUNICIPALITY_SLUG')]);
$municipality = $stmt->fetch(); $municipality = $stmt->fetch();
// Loads News for Moderation // Loads News for Moderation
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT news_id, title, content, author_name, published_at, created_at SELECT news_id, title, content, author_name, published_at, created_at
@@ -62,30 +67,6 @@ $stmt = $pdo->prepare("
$stmt->execute([':mid' => $municipality['municipality_id']]); $stmt->execute([':mid' => $municipality['municipality_id']]);
$news_items = $stmt->fetchAll(); $news_items = $stmt->fetchAll();
// Loads all Comments with Contribution Titles for Moderation
$stmt = $pdo->prepare("
SELECT cm.comment_id, cm.contribution_id, cm.author_name, cm.browser_id,
cm.content, cm.status, cm.created_at,
co.title AS contribution_title, co.category AS contribution_category
FROM comments cm
JOIN contributions co ON cm.contribution_id = co.contribution_id
WHERE co.municipality_id = :mid
ORDER BY cm.created_at DESC
");
$stmt->execute([':mid' => $municipality['municipality_id']]);
$all_comments = $stmt->fetchAll();
// Counts Comments per Status
$comment_counts = ['pending' => 0, 'approved' => 0, 'rejected' => 0];
foreach ($all_comments as $c) {
if (isset($comment_counts[$c['status']])) {
$comment_counts[$c['status']]++;
}
}
$comment_counts['total'] = count($all_comments);
// Shows Login Page if not authenticated // Shows Login Page if not authenticated
if ($page === 'login' || !is_admin()) { if ($page === 'login' || !is_admin()) {
show_login_page($municipality, $login_error ?? null); show_login_page($municipality, $login_error ?? null);
@@ -103,8 +84,8 @@ $categories = get_categories();
// Loads all Contributions for Municipality // Loads all Contributions for Municipality
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT contribution_id, title, category, description, author_name, photo_path, SELECT contribution_id, title, category, description, author_name,
geom_type, status, likes_count, dislikes_count, comment_count, created_at, updated_at geom_type, status, likes_count, dislikes_count, created_at, updated_at
FROM contributions FROM contributions
WHERE municipality_id = :mid WHERE municipality_id = :mid
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -178,9 +159,6 @@ $counts['total'] = count($all_contributions);
<button class="page-tab active" onclick="showPageTab('contributions')"> <button class="page-tab active" onclick="showPageTab('contributions')">
<i class="fa-solid fa-list-check"></i> Beiträge <i class="fa-solid fa-list-check"></i> Beiträge
</button> </button>
<button class="page-tab" onclick="showPageTab('comments')">
<i class="fa-solid fa-comments"></i> Kommentare
</button>
<button class="page-tab" onclick="showPageTab('news')"> <button class="page-tab" onclick="showPageTab('news')">
<i class="fa-solid fa-newspaper"></i> Neuigkeiten <i class="fa-solid fa-newspaper"></i> Neuigkeiten
</button> </button>
@@ -198,6 +176,27 @@ $counts['total'] = count($all_contributions);
<!-- ========================================================= --> <!-- ========================================================= -->
<div id="tab-contributions" class="page-tab-content"> <div id="tab-contributions" class="page-tab-content">
<!-- Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number"><?= $counts['total'] ?></div>
<div class="stat-label">Alle</div>
</div>
<div class="stat-card">
<div class="stat-number"><?= $counts['pending'] ?></div>
<div class="stat-label">Ausstehend</div>
</div>
<div class="stat-card">
<div class="stat-number"><?= $counts['approved'] ?></div>
<div class="stat-label">Akzeptiert</div>
</div>
<div class="stat-card">
<div class="stat-number"><?= $counts['rejected'] ?></div>
<div class="stat-label">Abgelehnt</div>
</div>
</div>
<!-- Status Filter Tabs --> <!-- Status Filter Tabs -->
<div class="filter-tabs"> <div class="filter-tabs">
<button class="filter-tab active" onclick="filterByStatus('all', this)"> <button class="filter-tab active" onclick="filterByStatus('all', this)">
@@ -258,28 +257,9 @@ $counts['total'] = count($all_contributions);
<!-- Expanded Detail --> <!-- Expanded Detail -->
<div class="contribution-row-detail"> <div class="contribution-row-detail">
<div class="detail-layout"> <div class="detail-layout">
<!-- Map and Photo Slider --> <!-- Map Preview -->
<div class="detail-slider" id="slider-<?= $item['contribution_id'] ?>"> <div class="detail-map" id="map-<?= $item['contribution_id'] ?>"
<!-- Slide 1: Map --> data-contribution-id="<?= $item['contribution_id'] ?>">
<div class="detail-slide active" data-slide="map">
<div class="detail-map" id="map-<?= $item['contribution_id'] ?>"
data-contribution-id="<?= $item['contribution_id'] ?>">
</div>
</div>
<?php if (!empty($item['photo_path'])): ?>
<!-- Slide 2: Photo -->
<div class="detail-slide" data-slide="photo" style="display:none;">
<img src="<?= htmlspecialchars($item['photo_path']) ?>" alt="Foto"
class="detail-slide-photo" onclick="window.open('<?= htmlspecialchars($item['photo_path']) ?>', '_blank')">
</div>
<!-- Slider Arrows -->
<button class="slider-arrow slider-arrow-left" onclick="slideDetail(<?= $item['contribution_id'] ?>, -1)">
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="slider-arrow slider-arrow-right" onclick="slideDetail(<?= $item['contribution_id'] ?>, 1)">
<i class="fa-solid fa-chevron-right"></i>
</button>
<?php endif; ?>
</div> </div>
<!-- Content --> <!-- Content -->
@@ -297,10 +277,6 @@ $counts['total'] = count($all_contributions);
<i class="fa-solid fa-thumbs-up"></i> <?= $item['likes_count'] ?> <i class="fa-solid fa-thumbs-up"></i> <?= $item['likes_count'] ?>
&middot; &middot;
<i class="fa-solid fa-thumbs-down"></i> <?= $item['dislikes_count'] ?> <i class="fa-solid fa-thumbs-down"></i> <?= $item['dislikes_count'] ?>
&middot;
<i class="fa-solid fa-comment"></i> <?= $item['comment_count'] ?? 0 ?>
</span> </span>
</div> </div>
</div> </div>
@@ -321,7 +297,7 @@ $counts['total'] = count($all_contributions);
<?php endif; ?> <?php endif; ?>
<?php if ($item['status'] !== 'pending'): ?> <?php if ($item['status'] !== 'pending'): ?>
<button class="btn btn-reset" onclick="changeStatus(<?= $item['contribution_id'] ?>, 'pending')"> <button class="btn btn-reset" onclick="changeStatus(..., 'pending')">
<i class="fa-solid fa-rotate-left"></i> Zurücksetzen <i class="fa-solid fa-rotate-left"></i> Zurücksetzen
</button> </button>
<?php endif; ?> <?php endif; ?>
@@ -346,113 +322,6 @@ $counts['total'] = count($all_contributions);
</div> </div>
<!-- ========================================================= -->
<!-- Comments Moderation Tab -->
<!-- ========================================================= -->
<div id="tab-comments" class="page-tab-content" style="display:none;">
<!-- Status Filter Tabs for Comments -->
<div class="filter-tabs" id="comment-filter-tabs">
<button class="filter-tab active" onclick="filterCommentsByStatus('all', this)">
Alle <span class="tab-count"><?= $comment_counts['total'] ?></span>
</button>
<button class="filter-tab" onclick="filterCommentsByStatus('pending', this)">
Ausstehend <span class="tab-count"><?= $comment_counts['pending'] ?></span>
</button>
<button class="filter-tab" onclick="filterCommentsByStatus('approved', this)">
Akzeptiert <span class="tab-count"><?= $comment_counts['approved'] ?></span>
</button>
<button class="filter-tab" onclick="filterCommentsByStatus('rejected', this)">
Abgelehnt <span class="tab-count"><?= $comment_counts['rejected'] ?></span>
</button>
</div>
<!-- Sort Controls -->
<div class="sort-controls">
<span id="comment-visible-count"><?= $comment_counts['total'] ?> Kommentare</span>
<select onchange="sortCommentRows(this.value)">
<option value="date-desc">Neueste zuerst</option>
<option value="date-asc">Älteste zuerst</option>
<option value="contribution">Nach Beitrag</option>
</select>
</div>
<!-- Comments List -->
<div id="comments-mod-container">
<?php if (empty($all_comments)): ?>
<div class="empty-state">
<i class="fa-solid fa-comments" style="font-size:2rem;margin-bottom:8px;display:block;"></i>
Noch keine Kommentare vorhanden.
</div>
<?php else: ?>
<?php foreach ($all_comments as $comment):
$comment_cat = $categories[$comment['contribution_category'] ?? ''] ?? ['label' => 'Unbekannt', 'faIcon' => 'fa-question', 'color' => '#999'];
$comment_status_label = ['pending' => 'Ausstehend', 'approved' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
?>
<div class="contribution-row comment-mod-row"
data-status="<?= $comment['status'] ?>"
data-date="<?= $comment['created_at'] ?>"
data-contribution="<?= htmlspecialchars($comment['contribution_title']) ?>">
<!-- Collapsed: Contribution Title + Comment Status + Category -->
<div class="contribution-row-header" onclick="toggleRow(this.parentElement)">
<div class="contribution-row-summary">
<span class="title"><?= htmlspecialchars($comment['contribution_title']) ?></span>
<span class="badge badge-<?= $comment['status'] ?>"><?= $comment_status_label[$comment['status']] ?? $comment['status'] ?></span>
<span class="badge badge-category">
<i class="fa-solid <?= $comment_cat['faIcon'] ?>"></i>
<?= $comment_cat['label'] ?>
</span>
</div>
<i class="fa-solid fa-chevron-down collapse-icon"></i>
</div>
<!-- Expanded Detail -->
<div class="contribution-row-detail">
<div style="padding:12px 0;">
<!-- Comment Content -->
<div style="font-size:0.9rem;line-height:1.6;color:var(--color-text);margin-bottom:12px;">
<?= nl2br(htmlspecialchars($comment['content'])) ?>
</div>
<!-- Meta -->
<div class="detail-meta">
<span><i class="fa-solid fa-user"></i> <?= htmlspecialchars($comment['author_name']) ?></span>
<span><i class="fa-solid fa-calendar"></i> <?= date('d.m.Y, H:i', strtotime($comment['created_at'])) ?> Uhr</span>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<?php if ($comment['status'] !== 'approved'): ?>
<button class="btn btn-approve" onclick="changeCommentStatus(<?= $comment['comment_id'] ?>, 'approved')">
<i class="fa-solid fa-check"></i> Akzeptieren
</button>
<?php endif; ?>
<?php if ($comment['status'] !== 'rejected'): ?>
<button class="btn btn-reject" onclick="changeCommentStatus(<?= $comment['comment_id'] ?>, 'rejected')">
<i class="fa-solid fa-xmark"></i> Ablehnen
</button>
<?php endif; ?>
<?php if ($comment['status'] !== 'pending'): ?>
<button class="btn btn-reset" onclick="changeCommentStatus(<?= $comment['comment_id'] ?>, 'pending')">
<i class="fa-solid fa-rotate-left"></i> Zurücksetzen
</button>
<?php endif; ?>
<button class="btn btn-edit" onclick="editModComment(<?= $comment['comment_id'] ?>, '<?= htmlspecialchars(addslashes($comment['content']), ENT_QUOTES) ?>')">
<i class="fa-solid fa-pen"></i> Bearbeiten
</button>
<button class="btn btn-delete" onclick="deleteModComment(<?= $comment['comment_id'] ?>)">
<i class="fa-solid fa-trash"></i> Löschen
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- ========================================================= --> <!-- ========================================================= -->
<!-- News Article Tab --> <!-- News Article Tab -->
<!-- ========================================================= --> <!-- ========================================================= -->
@@ -536,25 +405,13 @@ $counts['total'] = count($all_contributions);
// Current Status Filter // Current Status Filter
let currentFilter = 'all'; let currentFilter = 'all';
// Restores active Tab after Page Reload
const savedTab = sessionStorage.getItem('admin_active_tab');
if (savedTab) {
// Delays to ensure DOM is ready
setTimeout(function () {
const tabBtn = document.querySelector('.page-tab[onclick*="' + savedTab + '"]');
if (tabBtn) tabBtn.click();
}, 100);
}
// ============================================================= // =============================================================
// Page Tab Navigation // Page Tab Navigation
// ============================================================= // =============================================================
function showPageTab(tabName) { function showPageTab(tabName) {
// Saves active Tab for Persistence after Reload // Hides all Tab Contents
sessionStorage.setItem('admin_active_tab', tabName);
document.querySelectorAll('.page-tab-content').forEach(function (el) { document.querySelectorAll('.page-tab-content').forEach(function (el) {
el.style.display = 'none'; el.style.display = 'none';
}); });
@@ -595,39 +452,6 @@ $counts['total'] = count($all_contributions);
} }
// =============================================================
// Detail Slider for Maps and Photos
// =============================================================
function slideDetail(contributionId, direction) {
const slider = document.getElementById('slider-' + contributionId);
if (!slider) return;
const slides = slider.querySelectorAll('.detail-slide');
let activeIndex = -1;
// Finds currently active Slide
slides.forEach(function (slide, i) {
if (slide.style.display !== 'none') activeIndex = i;
});
// Calculates next Slide Index (wraps around)
const nextIndex = (activeIndex + direction + slides.length) % slides.length;
// Switches Slides
slides.forEach(function (slide) { slide.style.display = 'none'; });
slides[nextIndex].style.display = 'block';
// Loads Map if switching to Map Slide and not yet loaded
if (slides[nextIndex].dataset.slide === 'map') {
const mapDiv = slides[nextIndex].querySelector('.detail-map');
if (mapDiv && !mapDiv.dataset.loaded) {
loadMapPreview(mapDiv);
}
}
}
// ============================================================= // =============================================================
// Map Preview (Leaflet Mini Map per Contribution) // Map Preview (Leaflet Mini Map per Contribution)
// ============================================================= // =============================================================
@@ -797,7 +621,7 @@ $counts['total'] = count($all_contributions);
// ============================================================= // =============================================================
// Edit Contribution // Edit Contribution (Title and Description)
// ============================================================= // =============================================================
function editContribution(contributionId, currentTitle, currentDescription) { function editContribution(contributionId, currentTitle, currentDescription) {
@@ -1023,154 +847,6 @@ $counts['total'] = count($all_contributions);
}); });
} }
// =============================================================
// Sort Comments
// =============================================================
function sortCommentRows(sortBy) {
const container = document.getElementById('comments-mod-container');
const rows = Array.from(container.querySelectorAll('.comment-mod-row'));
rows.sort(function (a, b) {
if (sortBy === 'date-desc') {
return new Date(b.dataset.date) - new Date(a.dataset.date);
} else if (sortBy === 'date-asc') {
return new Date(a.dataset.date) - new Date(b.dataset.date);
} else if (sortBy === 'contribution') {
return a.dataset.contribution.localeCompare(b.dataset.contribution);
}
return 0;
});
rows.forEach(function (row) { container.appendChild(row); });
}
// =============================================================
// Delete Comments
// =============================================================
function deleteModComment(commentId) {
Swal.fire({
title: 'Kommentar löschen?',
text: 'Diese Aktion kann nicht rückgängig gemacht werden.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Löschen',
cancelButtonText: 'Abbrechen',
confirmButtonColor: '#c62828'
}).then(function (result) {
if (!result.isConfirmed) return;
apiCall({
action: 'delete_comment',
comment_id: commentId
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
Swal.fire('Gelöscht!', 'Kommentar wurde entfernt.', 'success')
.then(function () { location.reload(); });
});
});
}
// =============================================================
// Filter Comments by Status
// =============================================================
function filterCommentsByStatus(status, tabButton) {
// Updates active Filter Tab (only within Comments Tab)
document.querySelectorAll('#comment-filter-tabs .filter-tab').forEach(function (el) {
el.classList.remove('active');
});
tabButton.classList.add('active');
// Shows/Hides Comment Rows
let visibleCount = 0;
document.querySelectorAll('.comment-mod-row').forEach(function (row) {
if (status === 'all' || row.dataset.status === status) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
document.getElementById('comment-visible-count').textContent = visibleCount + ' Kommentare';
}
// =============================================================
// Change Comment Status (approve, reject, reset)
// =============================================================
function changeCommentStatus(commentId, newStatus) {
const labels = { approved: 'akzeptieren', rejected: 'ablehnen', pending: 'zurücksetzen' };
Swal.fire({
title: 'Kommentar ' + labels[newStatus] + '?',
showCancelButton: true,
confirmButtonText: 'Ja',
cancelButtonText: 'Abbrechen',
confirmButtonColor: PRIMARY_COLOR
}).then(function (result) {
if (!result.isConfirmed) return;
apiCall({
action: 'update_comment',
comment_id: commentId,
status: newStatus
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
location.reload();
});
});
}
// =============================================================
// Edit Comment Content
// =============================================================
function editModComment(commentId, currentContent) {
Swal.fire({
title: 'Kommentar bearbeiten',
html:
'<div style="text-align:left;">' +
'<label style="display:block;font-weight:600;font-size:1.15rem;margin-bottom:4px;">Inhalt</label>' +
'<textarea id="swal-comment-content" class="swal2-textarea" style="margin:0;width:100%;">' + currentContent + '</textarea>' +
'</div>',
showCancelButton: true,
confirmButtonText: 'Speichern',
cancelButtonText: 'Abbrechen',
confirmButtonColor: PRIMARY_COLOR,
preConfirm: function () {
return { content: document.getElementById('swal-comment-content').value.trim() };
}
}).then(function (result) {
if (!result.isConfirmed) return;
apiCall({
action: 'update_comment',
comment_id: commentId,
content: result.value.content
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
Swal.fire('Gespeichert!', 'Kommentar wurde aktualisiert.', 'success')
.then(function () { location.reload(); });
});
});
}
</script> </script>
</body> </body>

View File

@@ -56,9 +56,6 @@ switch ($action) {
case 'delete_comment': case 'delete_comment':
handle_delete_comment($input); handle_delete_comment($input);
break; break;
case 'update_comment':
handle_update_comment($input);
break;
default: default:
error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.'); error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.');
} }
@@ -86,8 +83,8 @@ function handle_read($input) {
$municipality_id = $input['municipality_id']; $municipality_id = $input['municipality_id'];
// Builds SQL Query with Placeholders for prepared Statement // Builds SQL Query with Placeholders for prepared Statement
$sql = "SELECT *, ST_AsGeoJSON(geom) AS geojson $sql = "SELECT *, ST_AsGeoJSON(geom) AS geojson
FROM contributions FROM contributions
WHERE municipality_id = :mid"; WHERE municipality_id = :mid";
$params = [':mid' => $municipality_id]; $params = [':mid' => $municipality_id];
@@ -579,9 +576,9 @@ function handle_read_comments($input) {
try { try {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT comment_id, contribution_id, author_name, browser_id, content, status, created_at SELECT comment_id, contribution_id, author_name, browser_id, content, created_at
FROM comments FROM comments
WHERE contribution_id = :cid AND status = 'approved' WHERE contribution_id = :cid
ORDER BY created_at ASC ORDER BY created_at ASC
"); ");
$stmt->execute([':cid' => $input['contribution_id']]); $stmt->execute([':cid' => $input['contribution_id']]);
@@ -632,6 +629,14 @@ function handle_create_comment($input) {
':content' => $input['content'] ':content' => $input['content']
]); ]);
$stmt2 = $pdo->prepare("
UPDATE contributions
SET comment_count = comment_count + 1
WHERE contribution_id = :cid;
");
$stmt2->execute([':cid' => $input['contribution_id']]);
json_response([ json_response([
'message' => 'Comment created successfully.', 'message' => 'Comment created successfully.',
'comment_id' => (int) $pdo->lastInsertId() 'comment_id' => (int) $pdo->lastInsertId()
@@ -659,54 +664,17 @@ function handle_delete_comment($input) {
$stmt = $pdo->prepare("DELETE FROM comments WHERE comment_id = :id"); $stmt = $pdo->prepare("DELETE FROM comments WHERE comment_id = :id");
$stmt->execute([':id' => $input['comment_id']]); $stmt->execute([':id' => $input['comment_id']]);
$stmt2 = $pdo->prepare("
UPDATE contributions
SET comment_count = comment_count - 1
WHERE contribution_id = :cid;
");
$stmt2->execute([':cid' => $input['contribution_id']]);
json_response(['message' => 'Comment deleted successfully.']); json_response(['message' => 'Comment deleted successfully.']);
} catch (PDOException $e) { } catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500); error_response('Database Error: ' . $e->getMessage(), 500);
} }
}
// ---------------------------------------------------------------------
// UPDATE COMMENT: Changes Comment Status or Content
// Required: comment_id
// Optional: status, content
// ---------------------------------------------------------------------
function handle_update_comment($input) {
$pdo = get_db();
$missing = validate_required($input, ['comment_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$set = [];
$params = [':id' => $input['comment_id']];
// Updates Status if provided
if (isset($input['status']) && $input['status'] !== '') {
$valid = ['pending', 'approved', 'rejected'];
if (!in_array($input['status'], $valid)) {
error_response('Invalid Status.');
}
$set[] = "status = :status";
$params[':status'] = $input['status'];
}
// Updates Content if provided
if (isset($input['content']) && $input['content'] !== '') {
$set[] = "content = :content";
$params[':content'] = $input['content'];
}
if (empty($set)) {
error_response('No Fields to update.');
}
try {
$stmt = $pdo->prepare("UPDATE comments SET " . implode(', ', $set) . " WHERE comment_id = :id");
$stmt->execute($params);
json_response(['message' => 'Comment updated successfully.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
} }

View File

@@ -17,8 +17,7 @@
const API_URL = 'api/contributions.php'; const API_URL = 'api/contributions.php';
// Username set via Login Modal stored in sessionStorage // Username set via Login Modal stored in sessionStorage
let currentUser = sessionStorage.getItem('webgis_user') || let currentUser = sessionStorage.getItem('webgis_user') || '';
decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)webgis_user\s*=\s*([^;]*).*$)|^.*$/, '$1')) || '';
// Browser Identification Number for anonymous User Identification stored as Cookie // Browser Identification Number for anonymous User Identification stored as Cookie
let browserId = getBrowserId(); let browserId = getBrowserId();
@@ -410,6 +409,11 @@ function buildPopupHtml(feature) {
if (props.photo_path) { if (props.photo_path) {
html += '<div class="popup-photo-container" id="photo-container-' + props.contribution_id + '" style="display:none;">' + html += '<div class="popup-photo-container" id="photo-container-' + props.contribution_id + '" style="display:none;">' +
'<img src="' + escapeHtml(props.photo_path) + '" alt="Foto" class="popup-photo-img" onclick="window.open(\'' + escapeHtml(props.photo_path) + '\', \'_blank\')">' + '<img src="' + escapeHtml(props.photo_path) + '" alt="Foto" class="popup-photo-img" onclick="window.open(\'' + escapeHtml(props.photo_path) + '\', \'_blank\')">' +
'</div>' +
'<div class="popup-photo-toggle">' +
'<button class="popup-photo-btn" onclick="togglePhoto(' + props.contribution_id + ')">' +
'<i class="fa-solid fa-camera"></i> <span id="photo-label-' + props.contribution_id + '">Foto anzeigen</span>' +
'</button>' +
'</div>'; '</div>';
} }
@@ -419,23 +423,15 @@ function buildPopupHtml(feature) {
' &middot; <i class="fa-solid fa-calendar"></i> ' + dateStr + ' &middot; <i class="fa-solid fa-calendar"></i> ' + dateStr +
'</div>'; '</div>';
// Vote Buttons and Photo Toggle // Vote Buttons
html += '<div class="popup-detail-votes">' + html += '<div class="popup-detail-votes">' +
'<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'like' ? ' liked' : '') + '" id="vote-like-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'like\')" title="Gefällt mir">' + '<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'like' ? ' liked' : '') + '" id="vote-like-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'like\')" title="Gefällt mir">' +
'<i class="fa-solid fa-thumbs-up"></i> <span id="likes-' + props.contribution_id + '">' + props.likes_count + '</span>' + '<i class="fa-solid fa-thumbs-up"></i> <span id="likes-' + props.contribution_id + '">' + props.likes_count + '</span>' +
'</button>' + '</button>' +
'<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'dislike' ? ' disliked' : '') + '" id="vote-dislike-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'dislike\')" title="Gefällt mir nicht">' + '<button class="popup-vote-btn' + (userVotes[props.contribution_id] === 'dislike' ? ' disliked' : '') + '" id="vote-dislike-' + props.contribution_id + '" onclick="voteContribution(' + props.contribution_id + ', \'dislike\')" title="Gefällt mir nicht">' +
'<i class="fa-solid fa-thumbs-down"></i> <span id="dislikes-' + props.contribution_id + '">' + props.dislikes_count + '</span>' + '<i class="fa-solid fa-thumbs-down"></i> <span id="dislikes-' + props.contribution_id + '">' + props.dislikes_count + '</span>' +
'</button>'; '</button>' +
'</div>';
// Photo Toggle Button
if (props.photo_path) {
html += '<button class="popup-vote-btn" onclick="togglePhoto(' + props.contribution_id + ')" title="Foto">' +
'<i class="fa-solid fa-camera"></i> <span id="photo-label-' + props.contribution_id + '">Foto anzeigen</span>' +
'</button>';
}
html += '</div>';
// Edit and Delete Buttons for Author or Admin // Edit and Delete Buttons for Author or Admin
if (props.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN)) { if (props.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN)) {
@@ -956,7 +952,6 @@ function submitLogin() {
} }
currentUser = name; currentUser = name;
sessionStorage.setItem('webgis_user', currentUser); sessionStorage.setItem('webgis_user', currentUser);
document.cookie = 'webgis_user=' + encodeURIComponent(name) + ';path=/;max-age=31536000;SameSite=Lax';
document.getElementById('login-modal').style.display = 'none'; document.getElementById('login-modal').style.display = 'none';
// Open Create Modal if Geometry is pending // Open Create Modal if Geometry is pending
@@ -1110,6 +1105,14 @@ function loadComments(contributionId) {
}); });
listContainer.innerHTML = html; listContainer.innerHTML = html;
const count = response.comments.length;
const header = document.querySelector('#comments-toggle-' + contributionId)?.closest('.popup-comments-header');
if (header) {
header.innerHTML = '<i class="fa-solid fa-comments"></i> Kommentare (' + count + ')' +
' <i class="fa-solid fa-chevron-down popup-comments-toggle" id="comments-toggle-' + contributionId + '"></i>';
}
}); });
} }
@@ -1131,14 +1134,9 @@ function submitComment(contributionId) {
Swal.fire('Fehler', response.error, 'error'); Swal.fire('Fehler', response.error, 'error');
return; return;
} }
// Clears Input and reloads Comments
if (input) input.value = ''; if (input) input.value = '';
Swal.fire({ loadComments(contributionId);
title: 'Eingereicht!',
text: 'Ihr Kommentar wurde erfolgreich eingereicht und wird nach Prüfung durch das Moderationsteam veröffentlicht.',
icon: 'success',
timer: 3000,
showConfirmButton: true
});
}); });
} }

View File

@@ -634,6 +634,10 @@ select.form-input { cursor: pointer; }
----------------------------------------------------------------- */ ----------------------------------------------------------------- */
/* Photo Toggle Button */ /* Photo Toggle Button */
.popup-photo-toggle {
margin: var(--space-sm) 0;
}
.popup-photo-btn { .popup-photo-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1032,60 +1036,6 @@ select.form-input { cursor: pointer; }
.back-link a { color: var(--color-text-secondary); } .back-link a { color: var(--color-text-secondary); }
/* -----------------------------------------------------------------
5.8 Detail Slider (Map/Photo in Admin)
----------------------------------------------------------------- */
.detail-slider {
width: 220px;
height: 170px;
flex-shrink: 0;
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
background: #f0f0f0;
}
.detail-slide { width: 100%; height: 100%; }
.detail-slide-photo {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.detail-slider .detail-map {
width: 100%;
height: 100%;
border: none;
border-radius: 0;
}
.slider-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
z-index: 1000;
transition: background var(--transition-fast);
}
.slider-arrow:hover { background: rgba(0, 0, 0, 0.7); }
.slider-arrow-left { left: 4px; }
.slider-arrow-right { right: 4px; }
/* ================================================================= /* =================================================================
SECTION 6: Responsive Overrides SECTION 6: Responsive Overrides
================================================================= */ ================================================================= */
@@ -1125,8 +1075,6 @@ select.form-input { cursor: pointer; }
.action-buttons .btn { justify-content: center; } .action-buttons .btn { justify-content: center; }
.filter-tabs { overflow-x: auto; } .filter-tabs { overflow-x: auto; }
.page-tabs { overflow-x: auto; } .page-tabs { overflow-x: auto; }
.detail-slider { width: 100%; height: 200px; }
/* Legal */ /* Legal */
.page-content-box { padding: 20px; } .page-content-box { padding: 20px; }