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:
Claude
2026-04-05 20:59:52 +00:00
commit 3c97192386
45 changed files with 2839 additions and 0 deletions

266
admin/article-edit.php Normal file
View 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
View 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
View 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>&nbsp;</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
View 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
View 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
View 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
View 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
View 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>&nbsp;</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">&#9776; <?= $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
View 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
View 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
View 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';

View 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>

View 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">&#9776;</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>

View 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
View 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]);