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';