405 lines
18 KiB
PHP
405 lines
18 KiB
PHP
<?php
|
|
// =====================================================================
|
|
// Moderation Page
|
|
// Lists pending Contributions for Review. Moderators can approve
|
|
// or reject Contributions.
|
|
// ToDo: Extend with News Management, User Management, Analytics.
|
|
// =====================================================================
|
|
|
|
require_once __DIR__ . '/api/db.php';
|
|
require_once __DIR__ . '/api/auth.php';
|
|
|
|
// -----------------------------------------------------------------
|
|
// Routing: Login, Logout, or Main Page
|
|
// -----------------------------------------------------------------
|
|
$page = $_GET['page'] ?? 'main';
|
|
|
|
// Handle Login Form Submission
|
|
if ($page === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$password = $_POST['password'] ?? '';
|
|
if (admin_login($password)) {
|
|
header('Location: admin.php');
|
|
exit;
|
|
} else {
|
|
$login_error = 'Falsches Passwort.';
|
|
}
|
|
}
|
|
|
|
// Handle Logout
|
|
if ($page === 'logout') {
|
|
admin_logout();
|
|
header('Location: admin.php?page=login');
|
|
exit;
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Load Municipality for Theming (needed for both Login and Main Page)
|
|
// -----------------------------------------------------------------
|
|
$pdo = get_db();
|
|
$stmt = $pdo->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'];
|
|
}
|
|
|
|
// 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
|
|
// -----------------------------------------------------------------
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Moderation — <?= htmlspecialchars($municipality['name']) ?></title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
<link rel="stylesheet" href="admin.css">
|
|
<style>:root { --color-primary: <?= htmlspecialchars($municipality['primary_color']) ?>; }</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="admin-header">
|
|
<h1><i class="fa-solid fa-shield-halved"></i> Moderation — <?= htmlspecialchars($municipality['name']) ?></h1>
|
|
<div class="admin-nav">
|
|
<a href="index.php"><i class="fa-solid fa-map"></i> Zur Karte</a>
|
|
<a href="admin.php?page=logout"><i class="fa-solid fa-right-from-bracket"></i> Abmelden</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-container">
|
|
|
|
<!-- Statistics -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-number"><?= ($stats['pending'] ?? 0) ?></div>
|
|
<div class="stat-label">Ausstehend</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number"><?= ($stats['approved'] ?? 0) ?></div>
|
|
<div class="stat-label">Freigegeben</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number"><?= ($stats['rejected'] ?? 0) ?></div>
|
|
<div class="stat-label">Abgelehnt</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number"><?= array_sum(array_column($stats_rows, 'count')) ?></div>
|
|
<div class="stat-label">Gesamt</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending Contributions -->
|
|
<div class="section">
|
|
<h2><i class="fa-solid fa-clock"></i> Ausstehende Beiträge (<?= count($pending) ?>)</h2>
|
|
|
|
<?php if (empty($pending)): ?>
|
|
<div class="empty-state">
|
|
<i class="fa-solid fa-check-circle" style="font-size:2rem;margin-bottom:8px;display:block;"></i>
|
|
Keine ausstehenden Beiträge.
|
|
</div>
|
|
<?php else: ?>
|
|
<?php foreach ($pending as $item): ?>
|
|
<div class="contribution-row-collapsible">
|
|
<div class="contribution-row-header" onclick="toggleContribution(this)">
|
|
<div class="contribution-row-summary">
|
|
<span class="title"><?= htmlspecialchars($item['title']) ?></span>
|
|
<span class="badge badge-pending">ausstehend</span>
|
|
<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']) ?>
|
|
· <?= 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']): ?>
|
|
<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 class="action-buttons">
|
|
<form method="POST">
|
|
<input type="hidden" name="contribution_id" value="<?= $item['contribution_id'] ?>">
|
|
<input type="hidden" name="mod_action" value="approved">
|
|
<button type="submit" class="btn btn-approve"><i class="fa-solid fa-check"></i> Freigeben</button>
|
|
</form>
|
|
<form method="POST">
|
|
<input type="hidden" name="contribution_id" value="<?= $item['contribution_id'] ?>">
|
|
<input type="hidden" name="mod_action" value="rejected">
|
|
<button type="submit" class="btn btn-reject"><i class="fa-solid fa-xmark"></i> Ablehnen</button>
|
|
</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>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<!-- Recently Moderated -->
|
|
<div class="section">
|
|
<h2><i class="fa-solid fa-history"></i> Kürzlich moderiert</h2>
|
|
|
|
<?php if (empty($moderated)): ?>
|
|
<div class="empty-state">Noch keine moderierten Beiträge.</div>
|
|
<?php else: ?>
|
|
<?php foreach ($moderated as $item): ?>
|
|
<div class="contribution-row-collapsible">
|
|
<div class="contribution-row-header" onclick="toggleContribution(this)">
|
|
<div class="contribution-row-summary">
|
|
<span class="title"><?= htmlspecialchars($item['title']) ?></span>
|
|
<span class="badge badge-<?= $item['status'] ?>"><?= $item['status'] === 'approved' ? 'freigegeben' : 'abgelehnt' ?></span>
|
|
<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']) ?>
|
|
· <?= 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>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</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>
|
|
</html>
|
|
|
|
<?php
|
|
// -----------------------------------------------------------------
|
|
// Login Page
|
|
// -----------------------------------------------------------------
|
|
function show_login_page($municipality, $error = null) {
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Moderation — Anmeldung</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
<link rel="stylesheet" href="admin.css">
|
|
<style>:root { --color-primary: <?= htmlspecialchars($municipality['primary_color']) ?>; }</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-wrapper">
|
|
<div class="login-box">
|
|
<h1><i class="fa-solid fa-shield-halved"></i> Moderation</h1>
|
|
<p>Bitte geben Sie das Moderationspasswort ein.</p>
|
|
<?php if ($error): ?>
|
|
<div class="error"><?= htmlspecialchars($error) ?></div>
|
|
<?php endif; ?>
|
|
<form method="POST" action="admin.php?page=login">
|
|
<input type="password" name="password" placeholder="Passwort" autofocus>
|
|
<button type="submit"><i class="fa-solid fa-right-to-bracket"></i> Anmelden</button>
|
|
</form>
|
|
<div class="back-link"><a href="index.php">← Zurück zur Karte</a></div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
}
|
|
?>
|