rebuild moderation page with filter and sorting functions, CRUD operations, map preview function and shared categories

This commit is contained in:
2026-04-22 14:39:38 +02:00
parent 27d41c0847
commit adf863934e
2 changed files with 807 additions and 387 deletions

View File

@@ -1,7 +1,12 @@
/* =====================================================================
Moderation Page Styles
Moderation Page Styles
Separate Stylesheet for the Admin Moderation Interface.
===================================================================== */
/* -----------------------------------------------------------------
Base
----------------------------------------------------------------- */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -11,44 +16,52 @@ body {
font-size: 15px;
}
/* -----------------------------------------------------------------
Header
----------------------------------------------------------------- */
.admin-header {
background: var(--color-primary);
color: white;
padding: 16px 24px;
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.admin-header h1 { font-size: 1.2rem; }
.admin-header a {
color: white;
text-decoration: none;
opacity: 0.8;
font-size: 0.85rem;
.admin-header h1 {
font-size: 1.15rem;
font-weight: 600;
}
.admin-header a:hover { opacity: 1; }
.admin-nav {
display: flex;
gap: 16px;
align-items: center;
}
.admin-nav a {
color: white;
text-decoration: none;
opacity: 0.8;
font-size: 0.85rem;
transition: opacity 150ms ease;
}
.admin-nav a:hover { opacity: 1; }
/* -----------------------------------------------------------------
Container
----------------------------------------------------------------- */
.admin-container {
max-width: 900px;
max-width: 960px;
margin: 24px auto;
padding: 0 16px;
}
/* -----------------------------------------------------------------
Statistics Cards
----------------------------------------------------------------- */
@@ -56,7 +69,7 @@ body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 32px;
margin-bottom: 28px;
}
.stat-card {
@@ -79,17 +92,230 @@ body {
margin-top: 4px;
}
/* -----------------------------------------------------------------
Section Headers
Filter Tabs
----------------------------------------------------------------- */
h2 {
font-size: 1.1rem;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--color-primary);
.filter-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 0;
}
.section { margin-bottom: 40px; }
.filter-tab {
padding: 8px 16px;
border: none;
background: none;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
color: #5a5a7a;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 150ms ease, border-color 150ms ease;
}
.filter-tab:hover {
color: var(--color-primary);
}
.filter-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.filter-tab .tab-count {
background: #e0e0e0;
color: #5a5a7a;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 10px;
margin-left: 4px;
}
.filter-tab.active .tab-count {
background: var(--color-primary);
color: white;
}
/* -----------------------------------------------------------------
Sort Controls
----------------------------------------------------------------- */
.sort-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
font-size: 0.85rem;
color: #5a5a7a;
}
.sort-controls select {
padding: 4px 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
/* -----------------------------------------------------------------
Collapsible Contribution Rows
----------------------------------------------------------------- */
.contribution-row {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
transition: border-color 150ms ease;
}
.contribution-row:hover {
border-color: #bbb;
}
.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: 10px;
flex: 1;
min-width: 0;
}
.contribution-row-summary .title {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collapse-icon {
color: #999;
font-size: 0.75rem;
flex-shrink: 0;
transition: transform 200ms ease;
}
.contribution-row.open .collapse-icon {
transform: rotate(180deg);
}
/* -----------------------------------------------------------------
Contribution Detail View (expanded)
----------------------------------------------------------------- */
.contribution-row-detail {
padding: 0 16px 16px 16px;
border-top: 1px solid #f0f0f0;
display: none;
}
.contribution-row.open .contribution-row-detail {
display: block;
}
.detail-layout {
display: flex;
gap: 16px;
margin-top: 12px;
margin-bottom: 12px;
}
.detail-map {
width: 220px;
height: 170px;
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: 10px;
}
.detail-content .description.empty {
color: #bbb;
font-style: italic;
}
.detail-meta {
font-size: 0.8rem;
color: #999;
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-meta span {
display: flex;
align-items: center;
gap: 6px;
}
/* -----------------------------------------------------------------
Action Buttons
----------------------------------------------------------------- */
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.btn {
padding: 7px 14px;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
font-family: inherit;
transition: filter 150ms ease;
text-decoration: none;
}
.btn:hover { filter: brightness(1.1); }
.btn-approve { background: #2e7d32; color: white; }
.btn-reject { background: #c62828; color: white; }
.btn-edit { background: #1565C0; color: white; }
.btn-delete { background: #424242; color: white; }
.btn-map { background: #546E7A; color: white; }
/* -----------------------------------------------------------------
Badges
@@ -102,41 +328,13 @@ h2 {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.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
@@ -148,6 +346,70 @@ h2 {
font-size: 0.9rem;
}
/* -----------------------------------------------------------------
Section Spacing
----------------------------------------------------------------- */
.section { margin-bottom: 32px; }
/* -----------------------------------------------------------------
Placeholder Tabs (future Features)
----------------------------------------------------------------- */
.placeholder-content {
text-align: center;
padding: 60px 20px;
color: #bbb;
}
.placeholder-content i {
font-size: 2.5rem;
margin-bottom: 12px;
display: block;
}
.placeholder-content p {
font-size: 0.9rem;
}
/* -----------------------------------------------------------------
Navigation Tabs (Page Sections)
----------------------------------------------------------------- */
.page-tabs {
display: flex;
gap: 4px;
margin-bottom: 24px;
background: white;
padding: 4px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.page-tab {
padding: 8px 16px;
border: none;
background: none;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
color: #5a5a7a;
cursor: pointer;
border-radius: 6px;
transition: all 150ms ease;
display: flex;
align-items: center;
gap: 6px;
}
.page-tab:hover { background: #f0f0f0; }
.page-tab.active {
background: var(--color-primary);
color: white;
}
/* -----------------------------------------------------------------
Login Page
----------------------------------------------------------------- */
@@ -186,7 +448,7 @@ h2 {
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 12px;
font-family: 'Segoe UI', system-ui, sans-serif;
font-family: inherit;
}
.login-box input:focus {
@@ -205,12 +467,12 @@ h2 {
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: 'Segoe UI', system-ui, sans-serif;
font-family: inherit;
}
.login-box button:hover { filter: brightness(1.15); }
.error {
.login-error {
color: #c62828;
font-size: 0.85rem;
margin-bottom: 12px;
@@ -223,127 +485,19 @@ h2 {
.back-link a { color: #5a5a7a; }
/* -----------------------------------------------------------------
Mobile Responsive
----------------------------------------------------------------- */
.action-buttons {
width: 100%;
}
.action-buttons form {
flex: 1;
}
.action-buttons .btn {
width: 100%;
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) {
.admin-header {
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.admin-header h1 { font-size: 1rem; }
.detail-layout {
flex-direction: column;
}
@@ -353,9 +507,23 @@ h2 {
height: 180px;
}
.contribution-row-summary {
.contribution-row-summary .title {
max-width: 200px;
}
.action-buttons {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.action-buttons .btn {
justify-content: center;
}
.filter-tabs {
overflow-x: auto;
}
.page-tabs {
overflow-x: auto;
}
}

View File

@@ -1,20 +1,26 @@
<?php
// =====================================================================
// Moderation Page
// Lists pending Contributions for Review. Moderators can approve
// or reject Contributions.
// ToDo: Extend with News Management, User Management, Analytics.
// Lists Contributions for Review. Moderators can approve, reject,
// edit, and delete Contributions. Includes Map Preview and Filtering.
//
// ToDo's:
// - News Management Tab (Phase 3-6)
// - User Management Tab (Phase 3-3)
// - Analytics Dashboard Tab (Phase 4-6)
// - Comment Moderation Tab (Phase 4-1)
// =====================================================================
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
// Handles Login
if ($page === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if (admin_login($password)) {
@@ -25,7 +31,7 @@ if ($page === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
// Handle Logout
// Handles Logout
if ($page === 'logout') {
admin_logout();
header('Location: admin.php?page=login');
@@ -33,88 +39,50 @@ if ($page === 'logout') {
}
// -----------------------------------------------------------------
// Load Municipality for Theming (needed for both Login and Main Page)
// Loads Municipality Configuration for Theming
// -----------------------------------------------------------------
$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
// Shows Login Page if not authenticated
if ($page === 'login' || !is_admin()) {
show_login_page($municipality, $login_error ?? null);
exit;
}
// -----------------------------------------------------------------
// Handle Moderation Actions (Approve / Reject)
// Loads shared Category Definitions
// -----------------------------------------------------------------
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;
}
$categories = get_categories();
// -----------------------------------------------------------------
// Load Contributions Data
// Loads Contributions and Statistics
// -----------------------------------------------------------------
// Pending Contributions
// All Contributions for this Municipality
$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'
SELECT contribution_id, title, category, description, author_name,
geom_type, status, likes_count, dislikes_count, created_at, updated_at
FROM contributions
WHERE municipality_id = :mid
ORDER BY created_at DESC
");
$stmt->execute([':mid' => $municipality['municipality_id']]);
$pending = $stmt->fetchAll();
$all_contributions = $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'];
// Counts per Status
$counts = ['pending' => 0, 'approved' => 0, 'rejected' => 0];
foreach ($all_contributions as $item) {
if (isset($counts[$item['status']])) {
$counts[$item['status']]++;
}
}
// Category Labels for German Display
$category_labels = [
'mobility' => '🚲 Mobilität',
'building' => '🏗️ Bauen',
'energy' => '⚡ Energie',
'environment' => '🌳 Umwelt',
'industry' => '🏭 Industrie',
'consumption' => '🛒 Konsum',
'other' => '📌 Sonstiges'
];
$counts['total'] = count($all_contributions);
// -----------------------------------------------------------------
// Render Main Page
// Renders Main Page
// -----------------------------------------------------------------
?>
<!DOCTYPE html>
@@ -123,12 +91,18 @@ $category_labels = [
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moderation — <?= htmlspecialchars($municipality['name']) ?></title>
<link rel="icon" href="assets/icon-municipality.png" type="image/png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<link rel="stylesheet" href="admin.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11.14.0/dist/sweetalert2.all.min.js"></script>
<style>:root { --color-primary: <?= htmlspecialchars($municipality['primary_color']) ?>; }</style>
</head>
<body>
<!-- ============================================================= -->
<!-- Header -->
<!-- ============================================================= -->
<div class="admin-header">
<h1><i class="fa-solid fa-shield-halved"></i> Moderation — <?= htmlspecialchars($municipality['name']) ?></h1>
<div class="admin-nav">
@@ -139,176 +113,275 @@ $category_labels = [
<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>
<!-- ========================================================= -->
<!-- Page Navigation Tabs -->
<!-- ========================================================= -->
<div class="page-tabs">
<button class="page-tab active" onclick="showPageTab('contributions')">
<i class="fa-solid fa-list-check"></i> Beiträge
</button>
<button class="page-tab" onclick="showPageTab('news')">
<i class="fa-solid fa-newspaper"></i> Neuigkeiten
</button>
<button class="page-tab" onclick="showPageTab('stats')">
<i class="fa-solid fa-chart-bar"></i> Statistik
</button>
<button class="page-tab" onclick="showPageTab('users')">
<i class="fa-solid fa-users"></i> Nutzer
</button>
</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.
<!-- ========================================================= -->
<!-- Contributions Tab -->
<!-- ========================================================= -->
<div id="tab-contributions" class="page-tab-content">
<!-- Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number"><?= $counts['pending'] ?></div>
<div class="stat-label">Ausstehend</div>
</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 class="stat-card">
<div class="stat-number"><?= $counts['approved'] ?></div>
<div class="stat-label">Freigegeben</div>
</div>
<div class="stat-card">
<div class="stat-number"><?= $counts['rejected'] ?></div>
<div class="stat-label">Abgelehnt</div>
</div>
<div class="stat-card">
<div class="stat-number"><?= $counts['total'] ?></div>
<div class="stat-label">Gesamt</div>
</div>
</div>
<!-- Status Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab active" onclick="filterByStatus('all', this)">
Alle <span class="tab-count"><?= $counts['total'] ?></span>
</button>
<button class="filter-tab" onclick="filterByStatus('pending', this)">
Ausstehend <span class="tab-count"><?= $counts['pending'] ?></span>
</button>
<button class="filter-tab" onclick="filterByStatus('approved', this)">
Freigegeben <span class="tab-count"><?= $counts['approved'] ?></span>
</button>
<button class="filter-tab" onclick="filterByStatus('rejected', this)">
Abgelehnt <span class="tab-count"><?= $counts['rejected'] ?></span>
</button>
</div>
<!-- Sort Controls -->
<div class="sort-controls">
<span id="visible-count"><?= $counts['total'] ?> Beiträge</span>
<select onchange="sortContributions(this.value)">
<option value="date-desc">Neueste zuerst</option>
<option value="date-asc">Älteste zuerst</option>
<option value="category">Nach Kategorie</option>
</select>
</div>
<!-- Contribution List -->
<div id="contributions-container">
<?php if (empty($all_contributions)): ?>
<div class="empty-state">
<i class="fa-solid fa-inbox" style="font-size:2rem;margin-bottom:8px;display:block;"></i>
Keine Beiträge vorhanden.
</div>
<?php else: ?>
<?php foreach ($all_contributions as $item):
$cat = $categories[$item['category']] ?? ['label' => $item['category'], 'faIcon' => 'fa-question', 'color' => '#999'];
$status_label = ['pending' => 'Ausstehend', 'approved' => 'Freigegeben', 'rejected' => 'Abgelehnt'];
?>
<div class="contribution-row"
data-status="<?= $item['status'] ?>"
data-category="<?= htmlspecialchars($item['category']) ?>"
data-date="<?= $item['created_at'] ?>"
data-id="<?= $item['contribution_id'] ?>">
<!-- Collapsed Header: Title + Status -->
<div class="contribution-row-header" onclick="toggleRow(this.parentElement)">
<div class="contribution-row-summary">
<span class="title"><?= htmlspecialchars($item['title']) ?></span>
<span class="badge badge-<?= $item['status'] ?>"><?= $status_label[$item['status']] ?? $item['status'] ?></span>
</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'])) ?>
<i class="fa-solid fa-chevron-down collapse-icon"></i>
</div>
<!-- Expanded Detail -->
<div class="contribution-row-detail">
<div class="detail-layout">
<!-- Map Preview -->
<div class="detail-map" id="map-<?= $item['contribution_id'] ?>"
data-contribution-id="<?= $item['contribution_id'] ?>">
</div>
<!-- Content -->
<div class="detail-content">
<?php if ($item['description']): ?>
<div class="description"><?= htmlspecialchars($item['description']) ?></div>
<?php else: ?>
<div class="description empty">Keine Beschreibung vorhanden.</div>
<?php endif; ?>
<div class="detail-meta">
<span>
<i class="fa-solid <?= $cat['faIcon'] ?>" style="color:<?= $cat['color'] ?>;"></i>
<?= $cat['label'] ?>
</span>
<span><i class="fa-solid fa-user"></i> <?= htmlspecialchars($item['author_name']) ?></span>
<span><i class="fa-solid fa-calendar"></i> <?= date('d.m.Y, H:i', strtotime($item['created_at'])) ?> Uhr</span>
<span>
<i class="fa-solid fa-thumbs-up"></i> <?= $item['likes_count'] ?>
&middot;
<i class="fa-solid fa-thumbs-down"></i> <?= $item['dislikes_count'] ?>
</span>
</div>
</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>
<!-- Action Buttons -->
<div class="action-buttons">
<?php if ($item['status'] !== 'approved'): ?>
<button class="btn btn-approve" onclick="changeStatus(<?= $item['contribution_id'] ?>, 'approved')">
<i class="fa-solid fa-check"></i> Freigeben
</button>
<?php endif; ?>
<?php if ($item['status'] !== 'rejected'): ?>
<button class="btn btn-reject" onclick="changeStatus(<?= $item['contribution_id'] ?>, 'rejected')">
<i class="fa-solid fa-xmark"></i> Ablehnen
</button>
<?php endif; ?>
<?php if ($item['status'] !== 'pending'): ?>
<button class="btn btn-edit" onclick="changeStatus(<?= $item['contribution_id'] ?>, 'pending')" style="background:#f57f17;">
<i class="fa-solid fa-rotate-left"></i> Zurücksetzen
</button>
<?php endif; ?>
<button class="btn btn-edit" onclick="editContribution(<?= $item['contribution_id'] ?>, '<?= htmlspecialchars(addslashes($item['title']), ENT_QUOTES) ?>', '<?= htmlspecialchars(addslashes($item['description'] ?? ''), ENT_QUOTES) ?>')">
<i class="fa-solid fa-pen"></i> Bearbeiten
</button>
<button class="btn btn-delete" onclick="deleteContribution(<?= $item['contribution_id'] ?>)">
<i class="fa-solid fa-trash"></i> Löschen
</button>
<a class="btn btn-map" href="index.php" target="_blank">
<i class="fa-solid fa-map-location-dot"></i> Karte
</a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
</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; ?>
<!-- ========================================================= -->
<!-- Placeholder Tabs (future Features) -->
<!-- ========================================================= -->
<div id="tab-news" class="page-tab-content" style="display:none;">
<div class="placeholder-content">
<i class="fa-solid fa-newspaper"></i>
<p>Neuigkeiten verwalten — kommt in einer zukünftigen Version.</p>
</div>
</div>
<div id="tab-stats" class="page-tab-content" style="display:none;">
<div class="placeholder-content">
<i class="fa-solid fa-chart-bar"></i>
<p>Statistiken und Analysen — kommt in einer zukünftigen Version.</p>
</div>
</div>
<div id="tab-users" class="page-tab-content" style="display:none;">
<div class="placeholder-content">
<i class="fa-solid fa-users"></i>
<p>Nutzerverwaltung — kommt in einer zukünftigen Version.</p>
</div>
</div>
</div>
<!-- Leaflet for Map Previews -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<!-- ============================================================= -->
<!-- JavaScript: Leaflet, Interactions, API Calls -->
<!-- ============================================================= -->
<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';
const MUNICIPALITY_CENTER = [<?= $municipality['center_lat'] ?>, <?= $municipality['center_lng'] ?>];
const MUNICIPALITY_ID = <?= $municipality['municipality_id'] ?>;
const API_URL = 'api/contributions.php';
const PRIMARY_COLOR = '<?= htmlspecialchars($municipality['primary_color']) ?>';
// Toggle Contribution Detail View
function toggleContribution(header) {
var detail = header.nextElementSibling;
var icon = header.querySelector('.collapse-icon');
var isOpen = detail.style.display !== 'none';
// Current Status Filter
let currentFilter = 'all';
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');
// =============================================================
// Page Tab Navigation
// =============================================================
function showPageTab(tabName) {
// Hides all Tab Contents
document.querySelectorAll('.page-tab-content').forEach(function (el) {
el.style.display = 'none';
});
// Deactivates all Tab Buttons
document.querySelectorAll('.page-tab').forEach(function (el) {
el.classList.remove('active');
});
// Shows selected Tab and activates Button
document.getElementById('tab-' + tabName).style.display = 'block';
event.currentTarget.classList.add('active');
}
// =============================================================
// Collapsible Rows
// =============================================================
function toggleRow(row) {
const wasOpen = row.classList.contains('open');
// Closes all open Rows
document.querySelectorAll('.contribution-row.open').forEach(function (el) {
el.classList.remove('open');
});
// Toggles clicked Row
if (!wasOpen) {
row.classList.add('open');
// Loads Map Preview if not already loaded
const mapDiv = row.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();
// =============================================================
// Map Preview (Leaflet Mini Map per Contribution)
// =============================================================
function loadMapPreview(mapDiv) {
const contributionId = mapDiv.dataset.contributionId;
// Fetches all Contributions to find the Geometry
const formData = new FormData();
formData.append('action', 'read');
formData.append('municipality_id', '<?= $municipality['municipality_id'] ?>');
formData.append('municipality_id', MUNICIPALITY_ID);
formData.append('status', 'all');
fetch(API_URL, { method: 'POST', body: formData })
@@ -316,18 +389,18 @@ $category_labels = [
.then(function (data) {
if (!data.features) return;
// Find the specific Contribution
var feature = data.features.find(function (f) {
// Finds the specific Contribution
const 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>';
mapDiv.innerHTML = '<div style="padding:20px;color:#999;text-align:center;font-size:0.8rem;">Geometrie nicht gefunden.</div>';
return;
}
// Create small Leaflet Map
var miniMap = L.map(mapDiv, {
// Creates Leaflet Mini Map
const miniMap = L.map(mapDiv, {
zoomControl: false,
attributionControl: false,
dragging: true,
@@ -338,20 +411,21 @@ $category_labels = [
maxZoom: 20
}).addTo(miniMap);
// Add Geometry to Map
var geojsonLayer = L.geoJSON(feature, {
style: { color: '#c62828', weight: 3, fillOpacity: 0.3 },
// Adds Geometry to Mini Map
const geojsonLayer = L.geoJSON(feature, {
style: { color: PRIMARY_COLOR, weight: 3, fillOpacity: 0.2 },
pointToLayer: function (f, latlng) {
return L.circleMarker(latlng, {
radius: 8, color: '#c62828', fillColor: '#ef5350', fillOpacity: 0.8, weight: 2
radius: 8, color: '#ffffff', weight: 2,
fillColor: PRIMARY_COLOR, fillOpacity: 0.9
});
}
}).addTo(miniMap);
// Fit Map to Geometry Bounds
var bounds = geojsonLayer.getBounds();
// Fits Map to Geometry Bounds
const bounds = geojsonLayer.getBounds();
if (bounds.isValid()) {
miniMap.fitBounds(bounds, { padding: [20, 20], maxZoom: 17 });
miniMap.fitBounds(bounds, { padding: [25, 25], maxZoom: 17 });
} else {
miniMap.setView(MUNICIPALITY_CENTER, 15);
}
@@ -359,14 +433,191 @@ $category_labels = [
mapDiv.dataset.loaded = 'true';
})
.catch(function () {
mapDiv.innerHTML = '<div style="padding:20px;color:#999;text-align:center;">Karte nicht verfügbar.</div>';
mapDiv.innerHTML = '<div style="padding:20px;color:#999;text-align:center;font-size:0.8rem;">Karte nicht verfügbar.</div>';
});
}
// =============================================================
// Status Filter
// =============================================================
function filterByStatus(status, tabButton) {
currentFilter = status;
// Updates active Tab
document.querySelectorAll('.filter-tab').forEach(function (el) {
el.classList.remove('active');
});
tabButton.classList.add('active');
// Shows/Hides Contribution Rows
let visibleCount = 0;
document.querySelectorAll('.contribution-row').forEach(function (row) {
if (status === 'all' || row.dataset.status === status) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// Updates Count Display
document.getElementById('visible-count').textContent = visibleCount + ' Beiträge';
}
// =============================================================
// Sort Contributions
// =============================================================
function sortContributions(sortBy) {
const container = document.getElementById('contributions-container');
const rows = Array.from(container.querySelectorAll('.contribution-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 === 'category') {
return a.dataset.category.localeCompare(b.dataset.category);
}
return 0;
});
// Reappends sorted Rows
rows.forEach(function (row) {
container.appendChild(row);
});
}
// =============================================================
// API Helper
// =============================================================
function apiCall(data) {
const formData = new FormData();
for (const key in data) {
formData.append(key, data[key]);
}
return fetch(API_URL, { method: 'POST', body: formData })
.then(function (r) { return r.json(); });
}
// =============================================================
// Change Contribution Status
// =============================================================
function changeStatus(contributionId, newStatus) {
const labels = { approved: 'freigeben', rejected: 'ablehnen', pending: 'zurücksetzen' };
Swal.fire({
title: 'Beitrag ' + labels[newStatus] + '?',
showCancelButton: true,
confirmButtonText: 'Ja',
cancelButtonText: 'Abbrechen',
confirmButtonColor: PRIMARY_COLOR
}).then(function (result) {
if (!result.isConfirmed) return;
apiCall({
action: 'update',
contribution_id: contributionId,
status: newStatus
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
// Reloads Page to reflect Changes
location.reload();
});
});
}
// =============================================================
// Edit Contribution (Title and Description)
// =============================================================
function editContribution(contributionId, currentTitle, currentDescription) {
Swal.fire({
title: 'Beitrag bearbeiten',
html:
'<div style="text-align:left;">' +
'<label style="font-weight:600;font-size:0.85rem;">Titel</label>' +
'<input id="swal-title" class="swal2-input" value="' + currentTitle + '">' +
'<label style="font-weight:600;font-size:0.85rem;">Beschreibung</label>' +
'<textarea id="swal-description" class="swal2-textarea">' + currentDescription + '</textarea>' +
'</div>',
showCancelButton: true,
confirmButtonText: 'Speichern',
cancelButtonText: 'Abbrechen',
confirmButtonColor: PRIMARY_COLOR,
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
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
Swal.fire('Gespeichert!', 'Beitrag wurde aktualisiert.', 'success')
.then(function () { location.reload(); });
});
});
}
// =============================================================
// Delete Contribution
// =============================================================
function deleteContribution(contributionId) {
Swal.fire({
title: 'Beitrag endgültig löschen?',
text: 'Diese Aktion kann nicht rückgängig gemacht werden.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Endgültig löschen',
cancelButtonText: 'Abbrechen',
confirmButtonColor: '#c62828'
}).then(function (result) {
if (!result.isConfirmed) return;
apiCall({
action: 'delete',
contribution_id: contributionId
}).then(function (response) {
if (response.error) {
Swal.fire('Fehler', response.error, 'error');
return;
}
Swal.fire('Gelöscht!', 'Beitrag wurde entfernt.', 'success')
.then(function () { location.reload(); });
});
});
}
</script>
</body>
</html>
<?php
// -----------------------------------------------------------------
// Login Page
@@ -379,6 +630,7 @@ function show_login_page($municipality, $error = null) {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moderation — Anmeldung</title>
<link rel="icon" href="assets/icon-municipality.png" type="image/png">
<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>
@@ -389,7 +641,7 @@ function show_login_page($municipality, $error = null) {
<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>
<div class="login-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="admin.php?page=login">
<input type="password" name="password" placeholder="Passwort" autofocus>