Initiales CMS: Deutschsprachiges Blog-System mit Admin-Bereich
Vollständiges, schlankes PHP/SQLite-CMS für IT-, KI- und Gaming-Inhalte: - Core: DB-Singleton, Auth mit Passwort-Hashing, Session-Cookies, CSRF-Schutz, Login-Rate-Limit, Bild-Upload mit serverseitiger Validierung - Admin: Dashboard, Artikel/Seiten-Verwaltung mit Quill WYSIWYG-Editor, Kategorien, Navigation (Drag & Drop), Medienbibliothek, Profil - Frontend: Responsive Dark-Theme, Artikel-Grid, Kategorie-Filter, Archiv, Paginierung, SEO-Meta-Tags - Sicherheit: Prepared Statements, HTML-Sanitizer, .htaccess-Schutz für sensible Verzeichnisse, PHP-Ausführungsschutz im Upload-Ordner - Installation: install.php erstellt DB-Schema und Admin-Account https://claude.ai/code/session_01Xsg4j2t4S9goMuWVpF3ezG
This commit is contained in:
266
admin/article-edit.php
Normal file
266
admin/article-edit.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
require_once __DIR__ . '/../core/upload.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
$article = null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$article = $stmt->fetch();
|
||||
if (!$article) {
|
||||
flash('error', 'Artikel nicht gefunden.');
|
||||
redirect('/admin/articles.php');
|
||||
}
|
||||
}
|
||||
|
||||
$categories = $pdo->query('SELECT id, name FROM categories ORDER BY sort_order, name')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$slug = trim($_POST['slug'] ?? '');
|
||||
$excerpt = trim($_POST['excerpt'] ?? '');
|
||||
$body = $_POST['body'] ?? '';
|
||||
$categoryId = !empty($_POST['category_id']) ? (int) $_POST['category_id'] : null;
|
||||
$status = in_array($_POST['status'] ?? '', ['draft', 'published']) ? $_POST['status'] : 'draft';
|
||||
$publishedAt = trim($_POST['published_at'] ?? '');
|
||||
|
||||
// Validierung
|
||||
$errors = [];
|
||||
if ($title === '') {
|
||||
$errors[] = 'Titel ist erforderlich.';
|
||||
}
|
||||
if ($slug === '') {
|
||||
$slug = slugify($title);
|
||||
} else {
|
||||
$slug = slugify($slug);
|
||||
}
|
||||
if ($slug === '') {
|
||||
$errors[] = 'Slug konnte nicht generiert werden.';
|
||||
}
|
||||
|
||||
// Slug-Eindeutigkeit prüfen
|
||||
$slugCheck = $pdo->prepare('SELECT id FROM articles WHERE slug = ? AND id != ?');
|
||||
$slugCheck->execute([$slug, $id ?? 0]);
|
||||
if ($slugCheck->fetch()) {
|
||||
$errors[] = 'Dieser Slug wird bereits verwendet.';
|
||||
}
|
||||
|
||||
// Body sanitieren
|
||||
$body = sanitize_html($body);
|
||||
|
||||
// Cover-Bild
|
||||
$coverImage = $article['cover_image'] ?? null;
|
||||
if (!empty($_FILES['cover_image']['name'])) {
|
||||
$uploaded = handle_upload($_FILES['cover_image']);
|
||||
if ($uploaded === false) {
|
||||
$errors[] = 'Bild-Upload fehlgeschlagen. Erlaubt: JPG, PNG, GIF, WebP (max. 5 MB).';
|
||||
} else {
|
||||
$coverImage = $uploaded;
|
||||
}
|
||||
}
|
||||
if (isset($_POST['remove_cover']) && $_POST['remove_cover'] === '1') {
|
||||
$coverImage = null;
|
||||
}
|
||||
|
||||
if ($status === 'published' && empty($publishedAt)) {
|
||||
$publishedAt = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (empty($errors)) {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE articles SET title=?, slug=?, excerpt=?, body=?, cover_image=?,
|
||||
category_id=?, status=?, published_at=?, updated_at=datetime('now') WHERE id=?"
|
||||
);
|
||||
$stmt->execute([$title, $slug, $excerpt, $body, $coverImage, $categoryId, $status, $publishedAt ?: null, $id]);
|
||||
flash('success', 'Artikel aktualisiert.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO articles (title, slug, excerpt, body, cover_image, category_id, status, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$title, $slug, $excerpt, $body, $coverImage, $categoryId, $status, $publishedAt ?: null]);
|
||||
$id = $pdo->lastInsertId();
|
||||
flash('success', 'Artikel erstellt.');
|
||||
}
|
||||
redirect('/admin/article-edit.php?id=' . $id);
|
||||
} else {
|
||||
foreach ($errors as $err) {
|
||||
flash('error', $err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = $article ? 'Artikel bearbeiten' : 'Neuer Artikel';
|
||||
$currentPage = 'articles';
|
||||
|
||||
$extraHead = '
|
||||
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
|
||||
';
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<form method="post" enctype="multipart/form-data" class="edit-form" id="articleForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-col-8">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel</label>
|
||||
<input type="text" id="title" name="title" required
|
||||
value="<?= e($article['title'] ?? $_POST['title'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug"
|
||||
value="<?= e($article['slug'] ?? $_POST['slug'] ?? '') ?>"
|
||||
placeholder="Wird automatisch aus dem Titel generiert">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="excerpt">Kurzfassung</label>
|
||||
<textarea id="excerpt" name="excerpt" rows="3"><?= e($article['excerpt'] ?? $_POST['excerpt'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Inhalt</label>
|
||||
<div id="editor"><?= $article['body'] ?? $_POST['body'] ?? '' ?></div>
|
||||
<textarea name="body" id="bodyHidden" style="display:none"><?= e($article['body'] ?? $_POST['body'] ?? '') ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-col-4">
|
||||
<div class="card sidebar-card">
|
||||
<h4>Veröffentlichung</h4>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="radio" name="status" value="draft"
|
||||
<?= ($article['status'] ?? 'draft') === 'draft' ? 'checked' : '' ?>>
|
||||
Entwurf
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="status" value="published"
|
||||
<?= ($article['status'] ?? '') === 'published' ? 'checked' : '' ?>>
|
||||
Veröffentlicht
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="published_at">Datum</label>
|
||||
<input type="datetime-local" id="published_at" name="published_at"
|
||||
value="<?= e(str_replace(' ', 'T', $article['published_at'] ?? '')) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category_id">Kategorie</label>
|
||||
<select id="category_id" name="category_id">
|
||||
<option value="">– Keine –</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?= $cat['id'] ?>"
|
||||
<?= ($article['category_id'] ?? '') == $cat['id'] ? 'selected' : '' ?>>
|
||||
<?= e($cat['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card sidebar-card">
|
||||
<h4>Titelbild</h4>
|
||||
<?php if (!empty($article['cover_image'])): ?>
|
||||
<div class="cover-preview">
|
||||
<img src="<?= e($article['cover_image']) ?>" alt="Cover">
|
||||
<label>
|
||||
<input type="checkbox" name="remove_cover" value="1"> Entfernen
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="form-group">
|
||||
<input type="file" name="cover_image" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
|
||||
$extraScripts = '
|
||||
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var quill = new Quill("#editor", {
|
||||
theme: "snow",
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{"header": [2, 3, 4, false]}],
|
||||
["bold", "italic", "underline"],
|
||||
[{"list": "ordered"}, {"list": "bullet"}],
|
||||
["blockquote", "code-block"],
|
||||
["link", "image"],
|
||||
["clean"]
|
||||
],
|
||||
handlers: {
|
||||
image: function() {
|
||||
var input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "image/*");
|
||||
input.click();
|
||||
input.onchange = function() {
|
||||
var file = input.files[0];
|
||||
if (!file) return;
|
||||
var formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("csrf_token", document.querySelector("[name=csrf_token]").value);
|
||||
fetch("/admin/upload-handler.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, "image", data.url);
|
||||
} else {
|
||||
alert(data.error || "Upload fehlgeschlagen");
|
||||
}
|
||||
})
|
||||
.catch(function() { alert("Upload fehlgeschlagen"); });
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("articleForm").addEventListener("submit", function() {
|
||||
document.getElementById("bodyHidden").value = quill.root.innerHTML;
|
||||
});
|
||||
|
||||
// Slug automatisch generieren
|
||||
var titleEl = document.getElementById("title");
|
||||
var slugEl = document.getElementById("slug");
|
||||
var slugManual = slugEl.value !== "";
|
||||
slugEl.addEventListener("input", function() { slugManual = slugEl.value !== ""; });
|
||||
titleEl.addEventListener("input", function() {
|
||||
if (!slugManual) {
|
||||
var s = titleEl.value.toLowerCase()
|
||||
.replace(/[äÄ]/g,"ae").replace(/[öÖ]/g,"oe").replace(/[üÜ]/g,"ue").replace(/ß/g,"ss")
|
||||
.replace(/[^a-z0-9\\s-]/g,"").replace(/[\\s-]+/g,"-").replace(/^-|-$/g,"");
|
||||
slugEl.value = s;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
';
|
||||
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
86
admin/articles.php
Normal file
86
admin/articles.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Artikel löschen
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_id'])) {
|
||||
csrf_verify();
|
||||
$stmt = $pdo->prepare('DELETE FROM articles WHERE id = ?');
|
||||
$stmt->execute([(int) $_POST['delete_id']]);
|
||||
flash('success', 'Artikel gelöscht.');
|
||||
redirect('/admin/articles.php');
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$total = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||||
$pag = paginate($total, $page, ITEMS_PER_PAGE);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT a.id, a.title, a.slug, a.status, a.published_at, a.created_at, c.name as category_name
|
||||
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
|
||||
ORDER BY a.created_at DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->execute([$pag['limit'], $pag['offset']]);
|
||||
$articles = $stmt->fetchAll();
|
||||
|
||||
$pageTitle = 'Artikel';
|
||||
$currentPage = 'articles';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Alle Artikel (<?= $total ?>)</h3>
|
||||
<a href="/admin/article-edit.php" class="btn btn-primary btn-sm">Neuer Artikel</a>
|
||||
</div>
|
||||
<?php if (empty($articles)): ?>
|
||||
<p class="empty-state">Noch keine Artikel vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Status</th>
|
||||
<th>Datum</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($articles as $article): ?>
|
||||
<tr>
|
||||
<td><a href="/admin/article-edit.php?id=<?= $article['id'] ?>"><?= e($article['title']) ?></a></td>
|
||||
<td><?= e($article['category_name'] ?? '–') ?></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= $article['status'] === 'published' ? 'success' : 'warning' ?>">
|
||||
<?= $article['status'] === 'published' ? 'Veröffentlicht' : 'Entwurf' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_date($article['published_at'] ?? $article['created_at']) ?></td>
|
||||
<td class="actions">
|
||||
<a href="/admin/article-edit.php?id=<?= $article['id'] ?>" class="btn btn-sm">Bearbeiten</a>
|
||||
<form method="post" class="inline-form" onsubmit="return confirm('Artikel wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="delete_id" value="<?= $article['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($pag['total_pages'] > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php for ($i = 1; $i <= $pag['total_pages']; $i++): ?>
|
||||
<a href="?page=<?= $i ?>" class="<?= $i === $pag['current_page'] ? 'active' : '' ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
126
admin/categories.php
Normal file
126
admin/categories.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
if (isset($_POST['delete_id'])) {
|
||||
$stmt = $pdo->prepare('DELETE FROM categories WHERE id = ?');
|
||||
$stmt->execute([(int) $_POST['delete_id']]);
|
||||
flash('success', 'Kategorie gelöscht.');
|
||||
redirect('/admin/categories.php');
|
||||
}
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
$catId = !empty($_POST['cat_id']) ? (int) $_POST['cat_id'] : null;
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$sortOrder = (int) ($_POST['sort_order'] ?? 0);
|
||||
|
||||
if ($name === '') {
|
||||
flash('error', 'Name ist erforderlich.');
|
||||
} else {
|
||||
$slug = slugify($name);
|
||||
$slugCheck = $pdo->prepare('SELECT id FROM categories WHERE slug = ? AND id != ?');
|
||||
$slugCheck->execute([$slug, $catId ?? 0]);
|
||||
if ($slugCheck->fetch()) {
|
||||
flash('error', 'Eine Kategorie mit diesem Namen existiert bereits.');
|
||||
} else {
|
||||
if ($catId) {
|
||||
$stmt = $pdo->prepare('UPDATE categories SET name=?, slug=?, description=?, sort_order=? WHERE id=?');
|
||||
$stmt->execute([$name, $slug, $description, $sortOrder, $catId]);
|
||||
flash('success', 'Kategorie aktualisiert.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare('INSERT INTO categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)');
|
||||
$stmt->execute([$name, $slug, $description, $sortOrder]);
|
||||
flash('success', 'Kategorie erstellt.');
|
||||
}
|
||||
}
|
||||
}
|
||||
redirect('/admin/categories.php');
|
||||
}
|
||||
}
|
||||
|
||||
$categories = $pdo->query('SELECT * FROM categories ORDER BY sort_order, name')->fetchAll();
|
||||
$editCat = null;
|
||||
if (isset($_GET['edit'])) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM categories WHERE id = ?');
|
||||
$stmt->execute([(int) $_GET['edit']]);
|
||||
$editCat = $stmt->fetch();
|
||||
}
|
||||
|
||||
$pageTitle = 'Kategorien';
|
||||
$currentPage = 'categories';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card">
|
||||
<h3><?= $editCat ? 'Kategorie bearbeiten' : 'Neue Kategorie' ?></h3>
|
||||
<form method="post" class="inline-edit-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($editCat): ?>
|
||||
<input type="hidden" name="cat_id" value="<?= $editCat['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<div class="form-row-inline">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required
|
||||
value="<?= e($editCat['name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<input type="text" id="description" name="description"
|
||||
value="<?= e($editCat['description'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group" style="width:80px">
|
||||
<label for="sort_order">Reihenfolge</label>
|
||||
<input type="number" id="sort_order" name="sort_order"
|
||||
value="<?= (int) ($editCat['sort_order'] ?? 0) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button type="submit" name="save" value="1" class="btn btn-primary">Speichern</button>
|
||||
<?php if ($editCat): ?>
|
||||
<a href="/admin/categories.php" class="btn">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Alle Kategorien (<?= count($categories) ?>)</h3>
|
||||
<?php if (empty($categories)): ?>
|
||||
<p class="empty-state">Noch keine Kategorien vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Slug</th><th>Beschreibung</th><th>Reihenfolge</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<tr>
|
||||
<td><?= e($cat['name']) ?></td>
|
||||
<td><?= e($cat['slug']) ?></td>
|
||||
<td><?= e($cat['description']) ?></td>
|
||||
<td><?= $cat['sort_order'] ?></td>
|
||||
<td class="actions">
|
||||
<a href="?edit=<?= $cat['id'] ?>" class="btn btn-sm">Bearbeiten</a>
|
||||
<form method="post" class="inline-form" onsubmit="return confirm('Kategorie wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="delete_id" value="<?= $cat['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
78
admin/index.php
Normal file
78
admin/index.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$articleCount = (int) $pdo->query("SELECT COUNT(*) FROM articles WHERE status='published'")->fetchColumn();
|
||||
$draftCount = (int) $pdo->query("SELECT COUNT(*) FROM articles WHERE status='draft'")->fetchColumn();
|
||||
$pageCount = (int) $pdo->query("SELECT COUNT(*) FROM pages")->fetchColumn();
|
||||
$categoryCount = (int) $pdo->query("SELECT COUNT(*) FROM categories")->fetchColumn();
|
||||
|
||||
$recentArticles = $pdo->query(
|
||||
"SELECT a.id, a.title, a.status, a.created_at, c.name as category_name
|
||||
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
|
||||
ORDER BY a.created_at DESC LIMIT 5"
|
||||
)->fetchAll();
|
||||
|
||||
$pageTitle = 'Dashboard';
|
||||
$currentPage = 'dashboard';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-number"><?= $articleCount ?></span>
|
||||
<span class="stat-label">Veröffentlicht</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number"><?= $draftCount ?></span>
|
||||
<span class="stat-label">Entwürfe</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number"><?= $pageCount ?></span>
|
||||
<span class="stat-label">Seiten</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number"><?= $categoryCount ?></span>
|
||||
<span class="stat-label">Kategorien</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Letzte Artikel</h3>
|
||||
<a href="/admin/article-edit.php" class="btn btn-primary btn-sm">Neuer Artikel</a>
|
||||
</div>
|
||||
<?php if (empty($recentArticles)): ?>
|
||||
<p class="empty-state">Noch keine Artikel vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Status</th>
|
||||
<th>Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentArticles as $article): ?>
|
||||
<tr>
|
||||
<td><a href="/admin/article-edit.php?id=<?= $article['id'] ?>"><?= e($article['title']) ?></a></td>
|
||||
<td><?= e($article['category_name'] ?? '–') ?></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= $article['status'] === 'published' ? 'success' : 'warning' ?>">
|
||||
<?= $article['status'] === 'published' ? 'Veröffentlicht' : 'Entwurf' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_date($article['created_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
51
admin/login.php
Normal file
51
admin/login.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
|
||||
if (auth_is_logged_in()) {
|
||||
redirect('/admin/');
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
if (!auth_check_rate_limit()) {
|
||||
$error = 'Zu viele Anmeldeversuche. Bitte warte einige Minuten.';
|
||||
} else {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
$error = 'Bitte Benutzername und Passwort eingeben.';
|
||||
} elseif (auth_login($username, $password)) {
|
||||
redirect('/admin/');
|
||||
} else {
|
||||
$error = 'Ungültiger Benutzername oder Passwort.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Anmelden';
|
||||
ob_start();
|
||||
?>
|
||||
<form method="post" class="login-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($error): ?>
|
||||
<div class="flash flash-error"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus
|
||||
value="<?= e($_POST['username'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Anmelden</button>
|
||||
</form>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/login-layout.php';
|
||||
5
admin/logout.php
Normal file
5
admin/logout.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_logout();
|
||||
redirect('/admin/login.php');
|
||||
142
admin/media.php
Normal file
142
admin/media.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
require_once __DIR__ . '/../core/upload.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
// Bild hochladen
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
if (isset($_POST['delete_file'])) {
|
||||
$file = $_POST['delete_file'];
|
||||
// Sicherstellen, dass der Pfad im Upload-Verzeichnis liegt
|
||||
$realUploadDir = realpath(UPLOAD_DIR);
|
||||
$realFile = realpath(UPLOAD_DIR . $file);
|
||||
if ($realFile && str_starts_with($realFile, $realUploadDir) && is_file($realFile)) {
|
||||
unlink($realFile);
|
||||
flash('success', 'Datei gelöscht.');
|
||||
} else {
|
||||
flash('error', 'Datei nicht gefunden.');
|
||||
}
|
||||
redirect('/admin/media.php');
|
||||
}
|
||||
|
||||
if (!empty($_FILES['images'])) {
|
||||
$files = $_FILES['images'];
|
||||
$uploaded = 0;
|
||||
$count = is_array($files['name']) ? count($files['name']) : 1;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$file = [
|
||||
'name' => is_array($files['name']) ? $files['name'][$i] : $files['name'],
|
||||
'type' => is_array($files['type']) ? $files['type'][$i] : $files['type'],
|
||||
'tmp_name' => is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'],
|
||||
'error' => is_array($files['error']) ? $files['error'][$i] : $files['error'],
|
||||
'size' => is_array($files['size']) ? $files['size'][$i] : $files['size'],
|
||||
];
|
||||
if ($file['error'] === UPLOAD_ERR_OK && handle_upload($file)) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uploaded > 0) {
|
||||
flash('success', $uploaded . ' Datei(en) hochgeladen.');
|
||||
} else {
|
||||
flash('error', 'Upload fehlgeschlagen.');
|
||||
}
|
||||
redirect('/admin/media.php');
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Bilder sammeln
|
||||
function scan_uploads(string $dir, string $prefix = ''): array
|
||||
{
|
||||
$files = [];
|
||||
if (!is_dir($dir)) return $files;
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..' || $item === '.htaccess' || $item === '.gitkeep') continue;
|
||||
$path = $dir . '/' . $item;
|
||||
if (is_dir($path)) {
|
||||
$files = array_merge($files, scan_uploads($path, $prefix . $item . '/'));
|
||||
} elseif (preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $item)) {
|
||||
$files[] = [
|
||||
'path' => $prefix . $item,
|
||||
'url' => UPLOAD_URL . $prefix . $item,
|
||||
'size' => filesize($path),
|
||||
'time' => filemtime($path),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
$images = scan_uploads(rtrim(UPLOAD_DIR, '/'));
|
||||
usort($images, fn($a, $b) => $b['time'] - $a['time']);
|
||||
|
||||
$pageTitle = 'Medien';
|
||||
$currentPage = 'media';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card">
|
||||
<h3>Bilder hochladen</h3>
|
||||
<form method="post" enctype="multipart/form-data" class="upload-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<p>Bilder hierher ziehen oder klicken</p>
|
||||
<input type="file" name="images[]" multiple accept="image/*" id="fileInput">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="margin-top:10px">Hochladen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Medienbibliothek (<?= count($images) ?>)</h3>
|
||||
<?php if (empty($images)): ?>
|
||||
<p class="empty-state">Noch keine Bilder vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<div class="media-grid">
|
||||
<?php foreach ($images as $img): ?>
|
||||
<div class="media-item">
|
||||
<img src="<?= e($img['url']) ?>" alt="" loading="lazy">
|
||||
<div class="media-actions">
|
||||
<button class="btn btn-sm" onclick="copyUrl('<?= e($img['url']) ?>')">URL kopieren</button>
|
||||
<form method="post" class="inline-form" onsubmit="return confirm('Bild wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="delete_file" value="<?= e($img['path']) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
|
||||
$extraScripts = '
|
||||
<script>
|
||||
function copyUrl(url) {
|
||||
var fullUrl = window.location.origin + url;
|
||||
navigator.clipboard.writeText(fullUrl).then(function() {
|
||||
alert("URL kopiert: " + fullUrl);
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var zone = document.getElementById("uploadZone");
|
||||
var input = document.getElementById("fileInput");
|
||||
zone.addEventListener("click", function() { input.click(); });
|
||||
zone.addEventListener("dragover", function(e) { e.preventDefault(); zone.classList.add("dragover"); });
|
||||
zone.addEventListener("dragleave", function() { zone.classList.remove("dragover"); });
|
||||
zone.addEventListener("drop", function(e) {
|
||||
e.preventDefault(); zone.classList.remove("dragover");
|
||||
input.files = e.dataTransfer.files;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
';
|
||||
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
212
admin/navigation.php
Normal file
212
admin/navigation.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
// Reihenfolge per AJAX aktualisieren
|
||||
if (isset($_POST['reorder'])) {
|
||||
$order = json_decode($_POST['reorder'], true);
|
||||
if (is_array($order)) {
|
||||
$stmt = $pdo->prepare('UPDATE navigation SET sort_order = ? WHERE id = ?');
|
||||
foreach ($order as $pos => $navId) {
|
||||
$stmt->execute([$pos, (int) $navId]);
|
||||
}
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
flash('success', 'Reihenfolge aktualisiert.');
|
||||
redirect('/admin/navigation.php');
|
||||
}
|
||||
|
||||
if (isset($_POST['delete_id'])) {
|
||||
$stmt = $pdo->prepare('DELETE FROM navigation WHERE id = ?');
|
||||
$stmt->execute([(int) $_POST['delete_id']]);
|
||||
flash('success', 'Navigationspunkt gelöscht.');
|
||||
redirect('/admin/navigation.php');
|
||||
}
|
||||
|
||||
if (isset($_POST['save'])) {
|
||||
$navId = !empty($_POST['nav_id']) ? (int) $_POST['nav_id'] : null;
|
||||
$label = trim($_POST['label'] ?? '');
|
||||
$type = $_POST['type'] ?? 'url';
|
||||
$target = trim($_POST['target'] ?? '');
|
||||
$sortOrder = (int) ($_POST['sort_order'] ?? 0);
|
||||
|
||||
if (!in_array($type, ['url', 'page', 'category', 'home'])) {
|
||||
$type = 'url';
|
||||
}
|
||||
if ($label === '') {
|
||||
flash('error', 'Bezeichnung ist erforderlich.');
|
||||
} else {
|
||||
if ($navId) {
|
||||
$stmt = $pdo->prepare('UPDATE navigation SET label=?, type=?, target=?, sort_order=? WHERE id=?');
|
||||
$stmt->execute([$label, $type, $target, $sortOrder, $navId]);
|
||||
flash('success', 'Navigationspunkt aktualisiert.');
|
||||
} else {
|
||||
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order),0) FROM navigation')->fetchColumn();
|
||||
$stmt = $pdo->prepare('INSERT INTO navigation (label, type, target, sort_order) VALUES (?, ?, ?, ?)');
|
||||
$stmt->execute([$label, $type, $target, $maxOrder + 1]);
|
||||
flash('success', 'Navigationspunkt erstellt.');
|
||||
}
|
||||
}
|
||||
redirect('/admin/navigation.php');
|
||||
}
|
||||
}
|
||||
|
||||
$navItems = $pdo->query('SELECT * FROM navigation ORDER BY sort_order')->fetchAll();
|
||||
$allPages = $pdo->query("SELECT id, title FROM pages WHERE status='published' ORDER BY title")->fetchAll();
|
||||
$allCategories = $pdo->query('SELECT id, name FROM categories ORDER BY sort_order, name')->fetchAll();
|
||||
|
||||
$editNav = null;
|
||||
if (isset($_GET['edit'])) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM navigation WHERE id = ?');
|
||||
$stmt->execute([(int) $_GET['edit']]);
|
||||
$editNav = $stmt->fetch();
|
||||
}
|
||||
|
||||
$pageTitle = 'Navigation';
|
||||
$currentPage = 'navigation';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card">
|
||||
<h3><?= $editNav ? 'Navigationspunkt bearbeiten' : 'Neuer Navigationspunkt' ?></h3>
|
||||
<form method="post" class="inline-edit-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($editNav): ?>
|
||||
<input type="hidden" name="nav_id" value="<?= $editNav['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<div class="form-row-inline">
|
||||
<div class="form-group">
|
||||
<label for="label">Bezeichnung</label>
|
||||
<input type="text" id="label" name="label" required
|
||||
value="<?= e($editNav['label'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Typ</label>
|
||||
<select id="type" name="type" onchange="updateTargetField(this.value)">
|
||||
<option value="home" <?= ($editNav['type'] ?? '') === 'home' ? 'selected' : '' ?>>Startseite</option>
|
||||
<option value="page" <?= ($editNav['type'] ?? '') === 'page' ? 'selected' : '' ?>>Seite</option>
|
||||
<option value="category" <?= ($editNav['type'] ?? '') === 'category' ? 'selected' : '' ?>>Kategorie</option>
|
||||
<option value="url" <?= ($editNav['type'] ?? '') === 'url' ? 'selected' : '' ?>>URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="targetGroup">
|
||||
<label for="target">Ziel</label>
|
||||
<input type="text" id="targetInput" name="target"
|
||||
value="<?= e($editNav['target'] ?? '') ?>" placeholder="URL eingeben">
|
||||
<select id="targetPageSelect" name="target_page" style="display:none">
|
||||
<option value="">– Seite wählen –</option>
|
||||
<?php foreach ($allPages as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= ($editNav['target'] ?? '') == $p['id'] ? 'selected' : '' ?>>
|
||||
<?= e($p['title']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select id="targetCatSelect" name="target_category" style="display:none">
|
||||
<option value="">– Kategorie wählen –</option>
|
||||
<?php foreach ($allCategories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>" <?= ($editNav['target'] ?? '') == $c['id'] ? 'selected' : '' ?>>
|
||||
<?= e($c['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button type="submit" name="save" value="1" class="btn btn-primary">Speichern</button>
|
||||
<?php if ($editNav): ?>
|
||||
<a href="/admin/navigation.php" class="btn">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Navigationspunkte</h3>
|
||||
<?php if (empty($navItems)): ?>
|
||||
<p class="empty-state">Keine Navigationspunkte vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table" id="navTable">
|
||||
<thead>
|
||||
<tr><th>Reihenfolge</th><th>Bezeichnung</th><th>Typ</th><th>Ziel</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody id="navBody">
|
||||
<?php foreach ($navItems as $nav): ?>
|
||||
<tr data-id="<?= $nav['id'] ?>">
|
||||
<td class="drag-handle" style="cursor:grab">☰ <?= $nav['sort_order'] ?></td>
|
||||
<td><?= e($nav['label']) ?></td>
|
||||
<td><?= e($nav['type']) ?></td>
|
||||
<td><?= e($nav['target']) ?></td>
|
||||
<td class="actions">
|
||||
<a href="?edit=<?= $nav['id'] ?>" class="btn btn-sm">Bearbeiten</a>
|
||||
<form method="post" class="inline-form" onsubmit="return confirm('Wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="delete_id" value="<?= $nav['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
|
||||
$extraScripts = '
|
||||
<script>
|
||||
function updateTargetField(type) {
|
||||
var input = document.getElementById("targetInput");
|
||||
var pageSelect = document.getElementById("targetPageSelect");
|
||||
var catSelect = document.getElementById("targetCatSelect");
|
||||
input.style.display = "none"; pageSelect.style.display = "none"; catSelect.style.display = "none";
|
||||
input.name = ""; pageSelect.name = ""; catSelect.name = "";
|
||||
if (type === "url") { input.style.display = ""; input.name = "target"; }
|
||||
else if (type === "page") { pageSelect.style.display = ""; pageSelect.name = "target"; }
|
||||
else if (type === "category") { catSelect.style.display = ""; catSelect.name = "target"; }
|
||||
else { input.value = ""; input.name = "target"; }
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
updateTargetField(document.getElementById("type").value);
|
||||
|
||||
// Drag & Drop Sortierung
|
||||
var tbody = document.getElementById("navBody");
|
||||
if (!tbody) return;
|
||||
var dragEl = null;
|
||||
tbody.querySelectorAll("tr").forEach(function(row) {
|
||||
row.draggable = true;
|
||||
row.addEventListener("dragstart", function(e) { dragEl = row; row.style.opacity = "0.4"; });
|
||||
row.addEventListener("dragend", function() { row.style.opacity = "1"; });
|
||||
row.addEventListener("dragover", function(e) { e.preventDefault(); });
|
||||
row.addEventListener("drop", function(e) {
|
||||
e.preventDefault();
|
||||
if (dragEl !== row) { tbody.insertBefore(dragEl, row); saveOrder(); }
|
||||
});
|
||||
});
|
||||
function saveOrder() {
|
||||
var ids = [];
|
||||
tbody.querySelectorAll("tr").forEach(function(r) { ids.push(r.dataset.id); });
|
||||
var formData = new FormData();
|
||||
formData.append("reorder", JSON.stringify(ids));
|
||||
formData.append("csrf_token", "<?= csrf_token() ?>");
|
||||
fetch("/admin/navigation.php", {
|
||||
method: "POST", body: formData,
|
||||
headers: {"X-Requested-With": "XMLHttpRequest"}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
';
|
||||
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
169
admin/page-edit.php
Normal file
169
admin/page-edit.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
$page = null;
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pages WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$page = $stmt->fetch();
|
||||
if (!$page) {
|
||||
flash('error', 'Seite nicht gefunden.');
|
||||
redirect('/admin/pages.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$slug = trim($_POST['slug'] ?? '');
|
||||
$body = $_POST['body'] ?? '';
|
||||
$status = in_array($_POST['status'] ?? '', ['draft', 'published']) ? $_POST['status'] : 'draft';
|
||||
|
||||
$errors = [];
|
||||
if ($title === '') {
|
||||
$errors[] = 'Titel ist erforderlich.';
|
||||
}
|
||||
if ($slug === '') {
|
||||
$slug = slugify($title);
|
||||
} else {
|
||||
$slug = slugify($slug);
|
||||
}
|
||||
|
||||
$slugCheck = $pdo->prepare('SELECT id FROM pages WHERE slug = ? AND id != ?');
|
||||
$slugCheck->execute([$slug, $id ?? 0]);
|
||||
if ($slugCheck->fetch()) {
|
||||
$errors[] = 'Dieser Slug wird bereits verwendet.';
|
||||
}
|
||||
|
||||
$body = sanitize_html($body);
|
||||
|
||||
if (empty($errors)) {
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("UPDATE pages SET title=?, slug=?, body=?, status=?, updated_at=datetime('now') WHERE id=?");
|
||||
$stmt->execute([$title, $slug, $body, $status, $id]);
|
||||
flash('success', 'Seite aktualisiert.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare('INSERT INTO pages (title, slug, body, status) VALUES (?, ?, ?, ?)');
|
||||
$stmt->execute([$title, $slug, $body, $status]);
|
||||
$id = $pdo->lastInsertId();
|
||||
flash('success', 'Seite erstellt.');
|
||||
}
|
||||
redirect('/admin/page-edit.php?id=' . $id);
|
||||
} else {
|
||||
foreach ($errors as $err) {
|
||||
flash('error', $err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = $page ? 'Seite bearbeiten' : 'Neue Seite';
|
||||
$currentPage = 'pages';
|
||||
|
||||
$extraHead = '<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">';
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<form method="post" class="edit-form" id="pageForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-col-8">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel</label>
|
||||
<input type="text" id="title" name="title" required
|
||||
value="<?= e($page['title'] ?? $_POST['title'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug"
|
||||
value="<?= e($page['slug'] ?? $_POST['slug'] ?? '') ?>"
|
||||
placeholder="Wird automatisch generiert">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Inhalt</label>
|
||||
<div id="editor"><?= $page['body'] ?? $_POST['body'] ?? '' ?></div>
|
||||
<textarea name="body" id="bodyHidden" style="display:none"><?= e($page['body'] ?? $_POST['body'] ?? '') ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-col-4">
|
||||
<div class="card sidebar-card">
|
||||
<h4>Status</h4>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="radio" name="status" value="draft"
|
||||
<?= ($page['status'] ?? 'draft') === 'draft' ? 'checked' : '' ?>>
|
||||
Entwurf
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="status" value="published"
|
||||
<?= ($page['status'] ?? '') === 'published' ? 'checked' : '' ?>>
|
||||
Veröffentlicht
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
|
||||
$extraScripts = '
|
||||
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var quill = new Quill("#editor", {
|
||||
theme: "snow",
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{"header": [2, 3, 4, false]}],
|
||||
["bold", "italic", "underline"],
|
||||
[{"list": "ordered"}, {"list": "bullet"}],
|
||||
["blockquote", "code-block"],
|
||||
["link", "image"],
|
||||
["clean"]
|
||||
],
|
||||
handlers: {
|
||||
image: function() {
|
||||
var input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "image/*");
|
||||
input.click();
|
||||
input.onchange = function() {
|
||||
var file = input.files[0];
|
||||
if (!file) return;
|
||||
var formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("csrf_token", document.querySelector("[name=csrf_token]").value);
|
||||
fetch("/admin/upload-handler.php", {method:"POST", body:formData})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(data){
|
||||
if(data.success){var range=quill.getSelection(true);quill.insertEmbed(range.index,"image",data.url);}
|
||||
else{alert(data.error||"Upload fehlgeschlagen");}
|
||||
}).catch(function(){alert("Upload fehlgeschlagen");});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById("pageForm").addEventListener("submit", function(){
|
||||
document.getElementById("bodyHidden").value = quill.root.innerHTML;
|
||||
});
|
||||
var titleEl=document.getElementById("title"),slugEl=document.getElementById("slug"),slugManual=slugEl.value!=="";
|
||||
slugEl.addEventListener("input",function(){slugManual=slugEl.value!=="";});
|
||||
titleEl.addEventListener("input",function(){
|
||||
if(!slugManual){slugEl.value=titleEl.value.toLowerCase().replace(/[äÄ]/g,"ae").replace(/[öÖ]/g,"oe").replace(/[üÜ]/g,"ue").replace(/ß/g,"ss").replace(/[^a-z0-9\\s-]/g,"").replace(/[\\s-]+/g,"-").replace(/^-|-$/g,"");}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
';
|
||||
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
67
admin/pages.php
Normal file
67
admin/pages.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_id'])) {
|
||||
csrf_verify();
|
||||
$stmt = $pdo->prepare('DELETE FROM pages WHERE id = ?');
|
||||
$stmt->execute([(int) $_POST['delete_id']]);
|
||||
flash('success', 'Seite gelöscht.');
|
||||
redirect('/admin/pages.php');
|
||||
}
|
||||
|
||||
$pages = $pdo->query('SELECT id, title, slug, status, created_at FROM pages ORDER BY created_at DESC')->fetchAll();
|
||||
|
||||
$pageTitle = 'Seiten';
|
||||
$currentPage = 'pages';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Alle Seiten (<?= count($pages) ?>)</h3>
|
||||
<a href="/admin/page-edit.php" class="btn btn-primary btn-sm">Neue Seite</a>
|
||||
</div>
|
||||
<?php if (empty($pages)): ?>
|
||||
<p class="empty-state">Noch keine Seiten vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Slug</th>
|
||||
<th>Status</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($pages as $pg): ?>
|
||||
<tr>
|
||||
<td><a href="/admin/page-edit.php?id=<?= $pg['id'] ?>"><?= e($pg['title']) ?></a></td>
|
||||
<td>/seite/<?= e($pg['slug']) ?></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= $pg['status'] === 'published' ? 'success' : 'warning' ?>">
|
||||
<?= $pg['status'] === 'published' ? 'Veröffentlicht' : 'Entwurf' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_date($pg['created_at']) ?></td>
|
||||
<td class="actions">
|
||||
<a href="/admin/page-edit.php?id=<?= $pg['id'] ?>" class="btn btn-sm">Bearbeiten</a>
|
||||
<form method="post" class="inline-form" onsubmit="return confirm('Seite wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="delete_id" value="<?= $pg['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
95
admin/profile.php
Normal file
95
admin/profile.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
auth_start_session();
|
||||
auth_require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_verify();
|
||||
|
||||
$currentPassword = $_POST['current_password'] ?? '';
|
||||
$newPassword = $_POST['new_password'] ?? '';
|
||||
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||
$displayName = trim($_POST['display_name'] ?? '');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
|
||||
$stmt->execute([auth_user_id()]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
$errors = [];
|
||||
|
||||
// Anzeigename aktualisieren
|
||||
if ($displayName !== '' && $displayName !== auth_display_name()) {
|
||||
$stmt = $pdo->prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?");
|
||||
$stmt->execute([$displayName, auth_user_id()]);
|
||||
$_SESSION['display_name'] = $displayName;
|
||||
flash('success', 'Anzeigename aktualisiert.');
|
||||
}
|
||||
|
||||
// Passwort ändern (nur wenn ausgefüllt)
|
||||
if ($newPassword !== '') {
|
||||
if (!password_verify($currentPassword, $user['password_hash'])) {
|
||||
$errors[] = 'Aktuelles Passwort ist falsch.';
|
||||
}
|
||||
if (strlen($newPassword) < 10) {
|
||||
$errors[] = 'Neues Passwort muss mindestens 10 Zeichen lang sein.';
|
||||
}
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
$errors[] = 'Passwörter stimmen nicht überein.';
|
||||
}
|
||||
if (empty($errors)) {
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?");
|
||||
$stmt->execute([$hash, auth_user_id()]);
|
||||
flash('success', 'Passwort geändert.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($errors as $err) {
|
||||
flash('error', $err);
|
||||
}
|
||||
redirect('/admin/profile.php');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT display_name, username FROM users WHERE id = ?');
|
||||
$stmt->execute([auth_user_id()]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
$pageTitle = 'Profil';
|
||||
$currentPage = 'profile';
|
||||
ob_start();
|
||||
?>
|
||||
<div class="card" style="max-width:500px">
|
||||
<h3>Profil bearbeiten</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="display_name">Anzeigename</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="<?= e($user['display_name']) ?>">
|
||||
</div>
|
||||
<hr>
|
||||
<h4>Passwort ändern</h4>
|
||||
<div class="form-group">
|
||||
<label for="current_password">Aktuelles Passwort</label>
|
||||
<input type="password" id="current_password" name="current_password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">Neues Passwort (mind. 10 Zeichen)</label>
|
||||
<input type="password" id="new_password" name="new_password" minlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Passwort bestätigen</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/templates/layout.php';
|
||||
18
admin/templates/_sidebar.php
Normal file
18
admin/templates/_sidebar.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="/admin/" class="sidebar-logo"><?= e(SITE_TITLE) ?></a>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/admin/" class="<?= ($currentPage ?? '') === 'dashboard' ? 'active' : '' ?>">Dashboard</a>
|
||||
<a href="/admin/articles.php" class="<?= ($currentPage ?? '') === 'articles' ? 'active' : '' ?>">Artikel</a>
|
||||
<a href="/admin/pages.php" class="<?= ($currentPage ?? '') === 'pages' ? 'active' : '' ?>">Seiten</a>
|
||||
<a href="/admin/categories.php" class="<?= ($currentPage ?? '') === 'categories' ? 'active' : '' ?>">Kategorien</a>
|
||||
<a href="/admin/navigation.php" class="<?= ($currentPage ?? '') === 'navigation' ? 'active' : '' ?>">Navigation</a>
|
||||
<a href="/admin/media.php" class="<?= ($currentPage ?? '') === 'media' ? 'active' : '' ?>">Medien</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="/admin/profile.php" class="<?= ($currentPage ?? '') === 'profile' ? 'active' : '' ?>">Profil</a>
|
||||
<a href="/" target="_blank">Seite anzeigen</a>
|
||||
<a href="/admin/logout.php">Abmelden</a>
|
||||
</div>
|
||||
</aside>
|
||||
26
admin/templates/layout.php
Normal file
26
admin/templates/layout.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($pageTitle ?? 'Admin') ?> - <?= e(SITE_TITLE) ?> Admin</title>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
<?= $extraHead ?? '' ?>
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<?php include __DIR__ . '/_sidebar.php'; ?>
|
||||
<main class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<button class="sidebar-toggle" id="sidebarToggle">☰</button>
|
||||
<h2><?= e($pageTitle ?? 'Admin') ?></h2>
|
||||
<span class="topbar-user"><?= e(auth_display_name()) ?></span>
|
||||
</header>
|
||||
<div class="admin-content">
|
||||
<?= flash_display() ?>
|
||||
<?= $content ?? '' ?>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
<?= $extraScripts ?? '' ?>
|
||||
</body>
|
||||
</html>
|
||||
16
admin/templates/login-layout.php
Normal file
16
admin/templates/login-layout.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($pageTitle ?? 'Login') ?> - <?= e(SITE_TITLE) ?> Admin</title>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-container">
|
||||
<h1 class="login-logo"><?= e(SITE_TITLE) ?></h1>
|
||||
<?= flash_display() ?>
|
||||
<?= $content ?? '' ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
43
admin/upload-handler.php
Normal file
43
admin/upload-handler.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX Upload-Endpoint für den WYSIWYG-Editor
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/auth.php';
|
||||
require_once __DIR__ . '/../core/upload.php';
|
||||
auth_start_session();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!auth_is_logged_in()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Nicht angemeldet.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Methode nicht erlaubt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF prüfen
|
||||
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals(csrf_token(), $token)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ungültiges Token.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['image'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Keine Datei ausgewählt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$url = handle_upload($_FILES['image']);
|
||||
if ($url === false) {
|
||||
echo json_encode(['success' => false, 'error' => 'Upload fehlgeschlagen. Erlaubt: JPG, PNG, GIF, WebP (max. 5 MB).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'url' => $url]);
|
||||
Reference in New Issue
Block a user