added map previews in moderation portal

This commit is contained in:
2026-04-20 17:04:33 +02:00
parent ad475390ce
commit 79b51e039f
2 changed files with 301 additions and 70 deletions

View File

@@ -91,41 +91,6 @@ h2 {
.section { margin-bottom: 40px; } .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 Badges
----------------------------------------------------------------- */ ----------------------------------------------------------------- */
@@ -261,11 +226,6 @@ h2 {
/* ----------------------------------------------------------------- /* -----------------------------------------------------------------
Mobile Responsive Mobile Responsive
----------------------------------------------------------------- */ ----------------------------------------------------------------- */
@media (max-width: 768px) {
.contribution-row {
flex-direction: column;
}
.action-buttons { .action-buttons {
width: 100%; width: 100%;
} }
@@ -278,4 +238,124 @@ h2 {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
/* -----------------------------------------------------------------
Collapsible Contribution Rows
----------------------------------------------------------------- */
.contribution-row-collapsible {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.contribution-row-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background 150ms ease;
}
.contribution-row-header:hover {
background: #f8f9fa;
}
.contribution-row-summary {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex: 1;
}
.contribution-row-summary .title {
font-weight: 600;
font-size: 0.95rem;
}
.meta-inline {
font-size: 0.8rem;
color: #5a5a7a;
}
.collapse-icon {
color: #999;
font-size: 0.8rem;
flex-shrink: 0;
transition: transform 200ms ease;
}
/* -----------------------------------------------------------------
Contribution Detail View (expanded)
----------------------------------------------------------------- */
.contribution-row-detail {
padding: 0 16px 16px 16px;
border-top: 1px solid #f0f0f0;
}
.detail-layout {
display: flex;
gap: 16px;
margin-bottom: 12px;
margin-top: 12px;
}
.detail-map {
width: 200px;
height: 160px;
border-radius: 6px;
border: 1px solid #e0e0e0;
flex-shrink: 0;
background: #f0f0f0;
}
.detail-content {
flex: 1;
min-width: 0;
}
.detail-content .description {
font-size: 0.85rem;
line-height: 1.5;
color: #5a5a7a;
margin-bottom: 8px;
}
.detail-content .meta {
font-size: 0.8rem;
color: #999;
}
.btn-map {
background: #1565C0;
color: white;
text-decoration: none;
}
.btn-map:hover {
background: #0d47a1;
}
/* -----------------------------------------------------------------
Mobile: Detail Layout stacked
----------------------------------------------------------------- */
@media (max-width: 768px) {
.detail-layout {
flex-direction: column;
}
.detail-map {
width: 100%;
height: 180px;
}
.contribution-row-summary {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
} }

View File

@@ -102,6 +102,17 @@ foreach ($stats_rows as $row) {
$stats[$row['status']] = $row['count']; $stats[$row['status']] = $row['count'];
} }
// Category Labels for German Display
$category_labels = [
'mobility' => '🚲 Mobilität',
'building' => '🏗️ Bauen',
'energy' => '⚡ Energie',
'environment' => '🌳 Umwelt',
'industry' => '🏭 Industrie',
'consumption' => '🛒 Konsum',
'other' => '📌 Sonstiges'
];
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Render Main Page // Render Main Page
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@@ -128,6 +139,7 @@ foreach ($stats_rows as $row) {
<div class="admin-container"> <div class="admin-container">
<!-- Statistics -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-number"><?= ($stats['pending'] ?? 0) ?></div> <div class="stat-number"><?= ($stats['pending'] ?? 0) ?></div>
@@ -147,6 +159,7 @@ foreach ($stats_rows as $row) {
</div> </div>
</div> </div>
<!-- Pending Contributions -->
<div class="section"> <div class="section">
<h2><i class="fa-solid fa-clock"></i> Ausstehende Beiträge (<?= count($pending) ?>)</h2> <h2><i class="fa-solid fa-clock"></i> Ausstehende Beiträge (<?= count($pending) ?>)</h2>
@@ -157,19 +170,36 @@ foreach ($stats_rows as $row) {
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach ($pending as $item): ?> <?php foreach ($pending as $item): ?>
<div class="contribution-row"> <div class="contribution-row-collapsible">
<div class="contribution-info"> <div class="contribution-row-header" onclick="toggleContribution(this)">
<div class="title"><?= htmlspecialchars($item['title']) ?></div> <div class="contribution-row-summary">
<div class="meta"> <span class="title"><?= htmlspecialchars($item['title']) ?></span>
<span class="badge badge-<?= $item['geom_type'] ?>"><?= $item['geom_type'] ?></span>
<span class="badge badge-pending">ausstehend</span> <span class="badge badge-pending">ausstehend</span>
· <?= htmlspecialchars($item['category']) ?> <span class="badge badge-<?= $item['geom_type'] ?>"><?= $item['geom_type'] ?></span>
<span class="meta-inline">
<?= $category_labels[$item['category']] ?? $item['category'] ?>
· <?= htmlspecialchars($item['author_name']) ?> · <?= htmlspecialchars($item['author_name']) ?>
· <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?> · <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?>
</span>
</div> </div>
<i class="fa-solid fa-chevron-down collapse-icon"></i>
</div>
<div class="contribution-row-detail" style="display:none;">
<div class="detail-layout">
<div class="detail-map" id="map-<?= $item['contribution_id'] ?>"
data-contribution-id="<?= $item['contribution_id'] ?>">
</div>
<div class="detail-content">
<?php if ($item['description']): ?> <?php if ($item['description']): ?>
<div class="description"><?= htmlspecialchars($item['description']) ?></div> <div class="description"><?= htmlspecialchars($item['description']) ?></div>
<?php else: ?>
<div class="description" style="color:#bbb;font-style:italic;">Keine Beschreibung.</div>
<?php endif; ?> <?php endif; ?>
<div class="meta">
<i class="fa-solid fa-user"></i> <?= htmlspecialchars($item['author_name']) ?>
· <i class="fa-solid fa-calendar"></i> <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?>
</div>
</div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
<form method="POST"> <form method="POST">
@@ -182,12 +212,17 @@ foreach ($stats_rows as $row) {
<input type="hidden" name="mod_action" value="rejected"> <input type="hidden" name="mod_action" value="rejected">
<button type="submit" class="btn btn-reject"><i class="fa-solid fa-xmark"></i> Ablehnen</button> <button type="submit" class="btn btn-reject"><i class="fa-solid fa-xmark"></i> Ablehnen</button>
</form> </form>
<a href="index.php#contribution-<?= $item['contribution_id'] ?>" class="btn btn-map" target="_blank">
<i class="fa-solid fa-map-location-dot"></i> Auf Karte
</a>
</div>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Recently Moderated -->
<div class="section"> <div class="section">
<h2><i class="fa-solid fa-history"></i> Kürzlich moderiert</h2> <h2><i class="fa-solid fa-history"></i> Kürzlich moderiert</h2>
@@ -195,15 +230,36 @@ foreach ($stats_rows as $row) {
<div class="empty-state">Noch keine moderierten Beiträge.</div> <div class="empty-state">Noch keine moderierten Beiträge.</div>
<?php else: ?> <?php else: ?>
<?php foreach ($moderated as $item): ?> <?php foreach ($moderated as $item): ?>
<div class="contribution-row"> <div class="contribution-row-collapsible">
<div class="contribution-info"> <div class="contribution-row-header" onclick="toggleContribution(this)">
<div class="title"><?= htmlspecialchars($item['title']) ?></div> <div class="contribution-row-summary">
<div class="meta"> <span class="title"><?= htmlspecialchars($item['title']) ?></span>
<span class="badge badge-<?= $item['geom_type'] ?>"><?= $item['geom_type'] ?></span>
<span class="badge badge-<?= $item['status'] ?>"><?= $item['status'] === 'approved' ? 'freigegeben' : 'abgelehnt' ?></span> <span class="badge badge-<?= $item['status'] ?>"><?= $item['status'] === 'approved' ? 'freigegeben' : 'abgelehnt' ?></span>
· <?= htmlspecialchars($item['category']) ?> <span class="badge badge-<?= $item['geom_type'] ?>"><?= $item['geom_type'] ?></span>
<span class="meta-inline">
<?= $category_labels[$item['category']] ?? $item['category'] ?>
· <?= htmlspecialchars($item['author_name']) ?> · <?= htmlspecialchars($item['author_name']) ?>
· <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?> · <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?>
</span>
</div>
<i class="fa-solid fa-chevron-down collapse-icon"></i>
</div>
<div class="contribution-row-detail" style="display:none;">
<div class="detail-layout">
<div class="detail-map" id="map-<?= $item['contribution_id'] ?>"
data-contribution-id="<?= $item['contribution_id'] ?>">
</div>
<div class="detail-content">
<?php if ($item['description'] ?? false): ?>
<div class="description"><?= htmlspecialchars($item['description']) ?></div>
<?php else: ?>
<div class="description" style="color:#bbb;font-style:italic;">Keine Beschreibung.</div>
<?php endif; ?>
<div class="meta">
<i class="fa-solid fa-user"></i> <?= htmlspecialchars($item['author_name']) ?>
· <i class="fa-solid fa-calendar"></i> <?= date('d.m.Y H:i', strtotime($item['created_at'])) ?>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -213,6 +269,101 @@ foreach ($stats_rows as $row) {
</div> </div>
<!-- Leaflet for Map Previews -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script>
// Municipality Configuration for Map Previews
var MUNICIPALITY_CENTER = [<?= $municipality['center_lat'] ?>, <?= $municipality['center_lng'] ?>];
var API_URL = 'api/contributions.php';
// Toggle Contribution Detail View
function toggleContribution(header) {
var detail = header.nextElementSibling;
var icon = header.querySelector('.collapse-icon');
var isOpen = detail.style.display !== 'none';
if (isOpen) {
detail.style.display = 'none';
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
} else {
detail.style.display = 'block';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
// Load Map Preview if not already loaded
var mapDiv = detail.querySelector('.detail-map');
if (mapDiv && !mapDiv.dataset.loaded) {
loadMapPreview(mapDiv);
}
}
}
// Load a small Leaflet Map Preview for a Contribution
function loadMapPreview(mapDiv) {
var contributionId = mapDiv.dataset.contributionId;
// Fetch Contribution Geometry from API
var formData = new FormData();
formData.append('action', 'read');
formData.append('municipality_id', '<?= $municipality['municipality_id'] ?>');
formData.append('status', 'all');
fetch(API_URL, { method: 'POST', body: formData })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.features) return;
// Find the specific Contribution
var feature = data.features.find(function (f) {
return f.properties.contribution_id == contributionId;
});
if (!feature) {
mapDiv.innerHTML = '<div style="padding:20px;color:#999;text-align:center;">Keine Geometrie gefunden.</div>';
return;
}
// Create small Leaflet Map
var miniMap = L.map(mapDiv, {
zoomControl: false,
attributionControl: false,
dragging: true,
scrollWheelZoom: false
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 20
}).addTo(miniMap);
// Add Geometry to Map
var geojsonLayer = L.geoJSON(feature, {
style: { color: '#c62828', weight: 3, fillOpacity: 0.3 },
pointToLayer: function (f, latlng) {
return L.circleMarker(latlng, {
radius: 8, color: '#c62828', fillColor: '#ef5350', fillOpacity: 0.8, weight: 2
});
}
}).addTo(miniMap);
// Fit Map to Geometry Bounds
var bounds = geojsonLayer.getBounds();
if (bounds.isValid()) {
miniMap.fitBounds(bounds, { padding: [20, 20], maxZoom: 17 });
} else {
miniMap.setView(MUNICIPALITY_CENTER, 15);
}
mapDiv.dataset.loaded = 'true';
})
.catch(function () {
mapDiv.innerHTML = '<div style="padding:20px;color:#999;text-align:center;">Karte nicht verfügbar.</div>';
});
}
</script>
</body> </body>
</html> </html>