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
267 lines
10 KiB
PHP
267 lines
10 KiB
PHP
<?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';
|