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
6 changed files with 153 additions and 274 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

@@ -57,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
@@ -68,20 +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 Comments with Contribution for Moderation
$stmt = $pdo->prepare("
SELECT cm.comment_id, cm.contribution_id, cm.author_name, cm.browser_id, cm.content, cm.created_at,
co.title AS contribution_title
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();
// 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);
@@ -99,7 +84,7 @@ $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, 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
@@ -174,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>
@@ -275,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 -->
@@ -314,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>
@@ -363,73 +322,6 @@ $counts['total'] = count($all_contributions);
</div> </div>
<!-- ========================================================= -->
<!-- Comments Moderation Tab -->
<!-- ========================================================= -->
<div id="tab-comments" class="page-tab-content" style="display:none;">
<!-- Sort Controls -->
<div class="sort-controls">
<span><?= count($all_comments) ?> 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): ?>
<div class="contribution-row comment-mod-row"
data-date="<?= $comment['created_at'] ?>"
data-contribution="<?= htmlspecialchars($comment['contribution_title']) ?>">
<div class="contribution-row-header" onclick="toggleRow(this.parentElement)">
<div class="contribution-row-summary">
<span class="title" style="flex:1;"><?= htmlspecialchars(mb_strimwidth($comment['content'], 0, 80, '...')) ?></span>
<span style="font-size:0.75rem;color:#999;">
<?= htmlspecialchars($comment['author_name']) ?>
· <?= date('d.m.Y H:i', strtotime($comment['created_at'])) ?>
</span>
</div>
<i class="fa-solid fa-chevron-down collapse-icon"></i>
</div>
<div class="contribution-row-detail">
<div style="padding:12px 0;">
<!-- Reference to Contribution -->
<div style="font-size:0.8rem;color:var(--color-text-secondary);margin-bottom:8px;padding:8px 12px;background:#f8f9fa;border-radius:6px;border-left:3px solid var(--color-primary);">
<i class="fa-solid fa-reply"></i> Beitrag: <strong><?= htmlspecialchars($comment['contribution_title']) ?></strong>
</div>
<!-- Comment Content -->
<div style="font-size:0.9rem;line-height:1.6;color:var(--color-text);margin-bottom:8px;">
<?= 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">
<button class="btn btn-delete" onclick="deleteModComment(<?= $comment['comment_id'] ?>)">
<i class="fa-solid fa-trash"></i> Kommentar löschen
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- ========================================================= --> <!-- ========================================================= -->
<!-- News Article Tab --> <!-- News Article Tab -->
<!-- ========================================================= --> <!-- ========================================================= -->
@@ -560,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)
// ============================================================= // =============================================================
@@ -762,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) {
@@ -988,60 +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(); });
});
});
}
</script> </script>
</body> </body>

View File

@@ -629,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()
@@ -656,6 +664,14 @@ 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) {

View File

@@ -1105,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>';
}
}); });
} }

View File

@@ -1036,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
================================================================= */ ================================================================= */
@@ -1129,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; }