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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
data/*.db
uploads/*
!uploads/.htaccess
!uploads/.gitkeep
!data/.htaccess
!data/.gitkeep
.DS_Store
Thumbs.db
*.swp
*.swo

29
.htaccess Normal file
View File

@@ -0,0 +1,29 @@
RewriteEngine On
RewriteBase /
# Zugriff auf sensible Verzeichnisse blockieren
RewriteRule ^(config|core|data)/ - [F,L]
# PHP-Ausführung in uploads verhindern
RewriteRule ^uploads/.*\.php$ - [F,L]
# Existierende Dateien/Verzeichnisse direkt ausliefern
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles andere durch index.php routen (außer admin/)
RewriteCond %{REQUEST_URI} !^/admin/
RewriteCond %{REQUEST_URI} !^/assets/
RewriteCond %{REQUEST_URI} !^/uploads/
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
# Sicherheits-Header
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "DENY"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Kein Directory Listing
Options -Indexes

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

245
assets/css/admin.css Normal file
View File

@@ -0,0 +1,245 @@
/* === DasPoschi Admin CSS === */
:root {
--bg-dark: #0f0f1a;
--bg-card: #1a1a2e;
--bg-sidebar: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-muted: #8892a4;
--accent: #00d4ff;
--accent-hover: #00b8d9;
--success: #00d474;
--warning: #ffb800;
--danger: #ff4757;
--border: #2a2a4a;
--radius: 6px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-dark);
color: var(--text);
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* === Login === */
.login-page {
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-container {
width: 100%; max-width: 400px; padding: 20px;
}
.login-logo {
text-align: center; font-size: 2rem; color: var(--accent);
margin-bottom: 30px;
}
.login-form .form-group { margin-bottom: 16px; }
.login-form label { display: block; margin-bottom: 4px; font-weight: 600; font-size: 0.9rem; }
.login-form input {
width: 100%; padding: 10px 12px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-size: 1rem;
}
.login-form input:focus { outline: none; border-color: var(--accent); }
/* === Layout === */
.admin-body { display: flex; min-height: 100vh; }
.admin-sidebar {
width: 240px; background: var(--bg-sidebar);
display: flex; flex-direction: column;
position: fixed; top: 0; left: 0; bottom: 0;
z-index: 100; transition: transform 0.3s;
}
.sidebar-header { padding: 20px; border-bottom: 1px solid var(--border); }
.sidebar-logo { font-size: 1.4rem; font-weight: 700; color: var(--accent); }
.sidebar-nav { flex: 1; padding: 10px 0; overflow-y: auto; }
.sidebar-nav a, .sidebar-footer a {
display: block; padding: 10px 20px; color: var(--text-muted);
transition: all 0.2s; font-size: 0.95rem;
}
.sidebar-nav a:hover, .sidebar-footer a:hover { background: rgba(0,212,255,0.1); color: var(--text); }
.sidebar-nav a.active { color: var(--accent); background: rgba(0,212,255,0.08); border-right: 3px solid var(--accent); }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 0; }
.admin-main { flex: 1; margin-left: 240px; }
.admin-topbar {
display: flex; align-items: center; gap: 16px;
padding: 16px 24px; background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.admin-topbar h2 { flex: 1; font-size: 1.2rem; font-weight: 600; }
.topbar-user { color: var(--text-muted); font-size: 0.9rem; }
.sidebar-toggle { display: none; background: none; border: none; color: var(--text); font-size: 1.5rem; cursor: pointer; }
.admin-content { padding: 24px; }
/* === Cards === */
.card {
background: var(--bg-card); border-radius: var(--radius);
padding: 20px; margin-bottom: 20px;
border: 1px solid var(--border);
}
.card h3 { margin-bottom: 16px; font-size: 1.1rem; }
.card h4 { margin-bottom: 12px; font-size: 1rem; color: var(--text-muted); }
.card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px;
}
.card-header h3 { margin-bottom: 0; }
.sidebar-card { margin-bottom: 16px; }
.sidebar-card label { display: block; margin-bottom: 6px; cursor: pointer; }
/* === Dashboard Stats === */
.dashboard-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; text-align: center;
}
.stat-number { display: block; font-size: 2.5rem; font-weight: 700; color: var(--accent); }
.stat-label { color: var(--text-muted); font-size: 0.9rem; }
/* === Tables === */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
padding: 10px 12px; text-align: left;
border-bottom: 1px solid var(--border);
}
.table th { color: var(--text-muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
.table tr:hover { background: rgba(255,255,255,0.02); }
/* === Buttons === */
.btn {
display: inline-block; padding: 8px 16px;
background: var(--bg-input); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 0.9rem; cursor: pointer; transition: all 0.2s;
text-align: center;
}
.btn:hover { background: var(--border); color: var(--text); }
.btn-primary { background: var(--accent); color: var(--bg-dark); border-color: var(--accent); font-weight: 600; }
.btn-primary:hover { background: var(--accent-hover); color: var(--bg-dark); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
.btn-block { display: block; width: 100%; }
/* === Forms === */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; font-weight: 600; font-size: 0.9rem; }
input[type="text"], input[type="password"], input[type="email"], input[type="number"],
input[type="datetime-local"], select, textarea {
width: 100%; padding: 10px 12px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-size: 0.95rem;
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent);
}
textarea { resize: vertical; min-height: 80px; }
input[type="file"] { padding: 8px 0; background: none; border: none; }
.form-row { display: flex; gap: 24px; }
.form-col-8 { flex: 2; }
.form-col-4 { flex: 1; min-width: 260px; }
.form-row-inline { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.form-row-inline .form-group { flex: 1; min-width: 150px; }
.edit-form hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.inline-form { display: inline; }
/* === Badges === */
.badge {
display: inline-block; padding: 2px 8px;
border-radius: 12px; font-size: 0.8rem; font-weight: 600;
}
.badge-success { background: rgba(0,212,116,0.15); color: var(--success); }
.badge-warning { background: rgba(255,184,0,0.15); color: var(--warning); }
/* === Flash Messages === */
.flash {
padding: 12px 16px; border-radius: var(--radius);
margin-bottom: 16px; font-size: 0.95rem;
}
.flash-success { background: rgba(0,212,116,0.1); border: 1px solid var(--success); color: var(--success); }
.flash-error { background: rgba(255,71,87,0.1); border: 1px solid var(--danger); color: var(--danger); }
/* === Pagination === */
.pagination {
display: flex; gap: 4px; margin-top: 16px; justify-content: center;
}
.pagination a {
padding: 6px 12px; border-radius: var(--radius);
background: var(--bg-input); color: var(--text-muted);
}
.pagination a.active { background: var(--accent); color: var(--bg-dark); font-weight: 600; }
.pagination a:hover { background: var(--border); color: var(--text); }
/* === Actions === */
.actions { white-space: nowrap; display: flex; gap: 6px; align-items: center; }
/* === Cover Preview === */
.cover-preview { margin-bottom: 12px; }
.cover-preview img { width: 100%; border-radius: var(--radius); margin-bottom: 8px; }
/* === Upload Zone === */
.upload-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 40px; text-align: center; cursor: pointer;
transition: all 0.2s; position: relative;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent); background: rgba(0,212,255,0.05);
}
.upload-zone input[type="file"] {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
opacity: 0; cursor: pointer;
}
/* === Media Grid === */
.media-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.media-item {
background: var(--bg-input); border-radius: var(--radius);
overflow: hidden; border: 1px solid var(--border);
}
.media-item img { width: 100%; height: 140px; object-fit: cover; display: block; }
.media-actions {
padding: 8px; display: flex; gap: 4px; flex-wrap: wrap;
}
/* === Quill Editor === */
.ql-toolbar.ql-snow { background: var(--bg-input); border-color: var(--border); border-radius: var(--radius) var(--radius) 0 0; }
.ql-container.ql-snow { border-color: var(--border); border-radius: 0 0 var(--radius) var(--radius); min-height: 300px; }
.ql-editor { color: var(--text); font-size: 1rem; line-height: 1.7; }
.ql-snow .ql-stroke { stroke: var(--text-muted); }
.ql-snow .ql-fill { fill: var(--text-muted); }
.ql-snow .ql-picker-label { color: var(--text-muted); }
.ql-snow .ql-picker-options { background: var(--bg-card); border-color: var(--border); }
.empty-state { color: var(--text-muted); padding: 20px 0; }
/* === Responsive === */
@media (max-width: 768px) {
.admin-sidebar { transform: translateX(-100%); }
.admin-sidebar.open { transform: translateX(0); }
.admin-main { margin-left: 0; }
.sidebar-toggle { display: block; }
.form-row { flex-direction: column; }
.form-col-4 { min-width: auto; }
.form-row-inline { flex-direction: column; }
.dashboard-stats { grid-template-columns: repeat(2, 1fr); }
}

214
assets/css/style.css Normal file
View File

@@ -0,0 +1,214 @@
/* === DasPoschi Public CSS === */
:root {
--bg: #0a0a14;
--bg-card: #12121f;
--bg-hover: #1a1a2e;
--text: #e0e0e0;
--text-muted: #8892a4;
--accent: #00d4ff;
--accent-hover: #00b8d9;
--border: #1e1e36;
--radius: 8px;
--max-width: 1100px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a { color: var(--accent); text-decoration: none; transition: color 0.2s; }
a:hover { color: var(--accent-hover); }
img { max-width: 100%; height: auto; }
.container { max-width: var(--max-width); margin: 0 auto; padding: 0 20px; }
/* === Header === */
.site-header {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 50;
}
.header-inner {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; gap: 20px;
}
.site-logo {
font-size: 1.5rem; font-weight: 800; color: var(--accent);
letter-spacing: -0.02em;
}
.site-nav { display: flex; gap: 24px; }
.site-nav a { color: var(--text-muted); font-weight: 500; font-size: 0.95rem; }
.site-nav a:hover { color: var(--accent); }
.menu-toggle {
display: none; background: none; border: none;
color: var(--text); font-size: 1.5rem; cursor: pointer;
}
/* === Main === */
.site-main { flex: 1; padding: 40px 0; }
/* === Hero === */
.hero {
text-align: center; padding: 40px 0 32px;
}
.hero h1 { font-size: 2.5rem; font-weight: 800; color: var(--accent); margin-bottom: 8px; }
.hero-subtitle { color: var(--text-muted); font-size: 1.2rem; }
/* === Category Bar === */
.category-bar {
display: flex; gap: 8px; justify-content: center;
flex-wrap: wrap; margin-bottom: 32px;
}
.category-tag {
display: inline-block; padding: 6px 16px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 20px; color: var(--text-muted);
font-size: 0.85rem; font-weight: 500; transition: all 0.2s;
}
.category-tag:hover { border-color: var(--accent); color: var(--accent); }
/* === Article Grid === */
.article-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px; margin-bottom: 32px;
}
/* === Article Card === */
.article-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
}
.article-card:hover { transform: translateY(-2px); border-color: rgba(0,212,255,0.3); }
.card-image img { width: 100%; height: 200px; object-fit: cover; display: block; }
.card-body { padding: 20px; }
.card-category {
display: inline-block; font-size: 0.8rem; font-weight: 600;
color: var(--accent); text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 8px;
}
.card-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 8px; line-height: 1.4; }
.card-title a { color: var(--text); }
.card-title a:hover { color: var(--accent); }
.card-excerpt { color: var(--text-muted); font-size: 0.95rem; margin-bottom: 12px; line-height: 1.6; }
.card-meta {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.85rem; color: var(--text-muted);
}
.read-more { font-weight: 600; }
/* === Single Article === */
.article-single { max-width: 800px; margin: 0 auto; }
.article-cover { margin: -20px -20px 32px; border-radius: var(--radius); overflow: hidden; }
.article-cover img { width: 100%; max-height: 450px; object-fit: cover; display: block; }
.article-header { margin-bottom: 32px; }
.article-category {
display: inline-block; font-size: 0.8rem; font-weight: 600;
color: var(--accent); text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 12px;
}
.article-header h1 { font-size: 2.2rem; font-weight: 800; line-height: 1.3; margin-bottom: 12px; }
.article-date { color: var(--text-muted); font-size: 0.95rem; }
.article-footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border); }
.back-link { color: var(--text-muted); font-weight: 500; }
.back-link:hover { color: var(--accent); }
/* === Prose (Article Body) === */
.prose { font-size: 1.05rem; line-height: 1.8; }
.prose h2 { font-size: 1.6rem; margin: 32px 0 16px; font-weight: 700; }
.prose h3 { font-size: 1.3rem; margin: 24px 0 12px; font-weight: 600; }
.prose h4 { font-size: 1.1rem; margin: 20px 0 10px; font-weight: 600; }
.prose p { margin-bottom: 16px; }
.prose ul, .prose ol { margin: 0 0 16px 24px; }
.prose li { margin-bottom: 4px; }
.prose blockquote {
border-left: 3px solid var(--accent); padding: 12px 20px;
margin: 20px 0; background: var(--bg-card); border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text-muted); font-style: italic;
}
.prose pre {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; overflow-x: auto;
margin: 20px 0; font-size: 0.9rem;
}
.prose code {
background: var(--bg-card); padding: 2px 6px;
border-radius: 4px; font-size: 0.9em;
}
.prose pre code { background: none; padding: 0; }
.prose img { border-radius: var(--radius); margin: 20px 0; }
.prose a { text-decoration: underline; text-underline-offset: 2px; }
.prose table {
width: 100%; border-collapse: collapse; margin: 20px 0;
}
.prose th, .prose td {
padding: 10px 12px; border: 1px solid var(--border); text-align: left;
}
.prose th { background: var(--bg-card); font-weight: 600; }
/* === Static Page === */
.static-page { max-width: 800px; margin: 0 auto; }
.static-page h1 { font-size: 2rem; font-weight: 800; margin-bottom: 24px; }
/* === Page Header === */
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
.page-description { color: var(--text-muted); font-size: 1.1rem; }
/* === Pagination === */
.pagination {
display: flex; gap: 6px; justify-content: center;
flex-wrap: wrap;
}
.pagination a {
padding: 8px 14px; border-radius: var(--radius);
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-muted); font-size: 0.9rem; transition: all 0.2s;
}
.pagination a.active { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
.pagination a:hover { border-color: var(--accent); color: var(--accent); }
/* === 404 === */
.error-page { text-align: center; padding: 80px 0; }
.error-page h1 { font-size: 6rem; font-weight: 800; color: var(--accent); margin-bottom: 16px; }
.error-page p { color: var(--text-muted); font-size: 1.2rem; margin-bottom: 24px; }
.btn {
display: inline-block; padding: 10px 20px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-weight: 500;
transition: all 0.2s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
/* === Footer === */
.site-footer {
padding: 24px 0; border-top: 1px solid var(--border);
text-align: center; color: var(--text-muted); font-size: 0.85rem;
}
.empty-state { color: var(--text-muted); text-align: center; padding: 40px 0; }
/* === Responsive === */
@media (max-width: 768px) {
.menu-toggle { display: block; }
.site-nav {
display: none; position: absolute; top: 100%; left: 0; right: 0;
background: var(--bg-card); border-bottom: 1px solid var(--border);
flex-direction: column; padding: 16px 20px; gap: 12px;
}
.site-nav.open { display: flex; }
.hero h1 { font-size: 1.8rem; }
.article-grid { grid-template-columns: 1fr; }
.article-header h1 { font-size: 1.6rem; }
.article-cover { margin: -20px -10px 24px; }
}

27
assets/js/admin.js Normal file
View File

@@ -0,0 +1,27 @@
document.addEventListener('DOMContentLoaded', function () {
// Sidebar Toggle (Mobile)
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.querySelector('.admin-sidebar');
if (toggle && sidebar) {
toggle.addEventListener('click', function () {
sidebar.classList.toggle('open');
});
// Sidebar schließen bei Klick außerhalb
document.addEventListener('click', function (e) {
if (sidebar.classList.contains('open') &&
!sidebar.contains(e.target) &&
e.target !== toggle) {
sidebar.classList.remove('open');
}
});
}
// Flash-Nachrichten automatisch ausblenden
document.querySelectorAll('.flash').forEach(function (el) {
setTimeout(function () {
el.style.transition = 'opacity 0.5s';
el.style.opacity = '0';
setTimeout(function () { el.remove(); }, 500);
}, 5000);
});
});

16
assets/js/main.js Normal file
View File

@@ -0,0 +1,16 @@
document.addEventListener('DOMContentLoaded', function () {
// Mobile Menü Toggle
var toggle = document.getElementById('menuToggle');
var nav = document.getElementById('siteNav');
if (toggle && nav) {
toggle.addEventListener('click', function () {
nav.classList.toggle('open');
});
}
// Lazy Loading für ältere Browser
if ('loading' in HTMLImageElement.prototype) return;
document.querySelectorAll('img[loading="lazy"]').forEach(function (img) {
img.src = img.src;
});
});

1
config/.htaccess Normal file
View File

@@ -0,0 +1 @@
Deny from all

20
config/config.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
/**
* Konfiguration - DasPoschi CMS
*/
define('DB_PATH', __DIR__ . '/../data/cms.db');
define('SITE_TITLE', 'DasPoschi');
define('SITE_DESCRIPTION', 'IT, KI & Gaming');
define('SITE_URL', ''); // Basis-URL, leer = relativ
define('TIMEZONE', 'Europe/Berlin');
define('UPLOAD_DIR', __DIR__ . '/../uploads/');
define('UPLOAD_URL', '/uploads/');
define('UPLOAD_MAX_SIZE', 5 * 1024 * 1024); // 5 MB
define('ALLOWED_MIME_TYPES', ['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
define('ITEMS_PER_PAGE', 10);
define('LOGIN_MAX_ATTEMPTS', 5);
define('LOGIN_LOCKOUT_MINUTES', 15);
define('SESSION_LIFETIME', 3600);

1
core/.htaccess Normal file
View File

@@ -0,0 +1 @@
Deny from all

132
core/auth.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
/**
* Authentifizierung, Session-Management, CSRF-Schutz, Rate-Limiting
*/
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/helpers.php';
function auth_start_session(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([
'lifetime' => SESSION_LIFETIME,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
// Session-ID alle 15 Minuten erneuern
if (!isset($_SESSION['_last_regeneration'])) {
$_SESSION['_last_regeneration'] = time();
} elseif (time() - $_SESSION['_last_regeneration'] > 900) {
session_regenerate_id(true);
$_SESSION['_last_regeneration'] = time();
}
}
function auth_is_logged_in(): bool
{
return !empty($_SESSION['user_id']);
}
function auth_require_login(): void
{
if (!auth_is_logged_in()) {
redirect('/admin/login.php');
}
}
function auth_login(string $username, string $password): bool
{
$stmt = db()->prepare('SELECT id, password_hash, display_name FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
auth_record_attempt();
return false;
}
// Session erneuern bei Login
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['display_name'] = $user['display_name'];
$_SESSION['_last_regeneration'] = time();
// Fehlversuche löschen
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$stmt = db()->prepare('DELETE FROM login_attempts WHERE ip_address = ?');
$stmt->execute([$ip]);
return true;
}
function auth_logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">';
}
function csrf_verify(): void
{
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals(csrf_token(), $token)) {
http_response_code(403);
die('Ungültiges CSRF-Token.');
}
}
function auth_check_rate_limit(): bool
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$stmt = db()->prepare(
'SELECT COUNT(*) FROM login_attempts WHERE ip_address = ? AND attempted_at > datetime("now", ?)'
);
$stmt->execute([$ip, '-' . LOGIN_LOCKOUT_MINUTES . ' minutes']);
$count = (int) $stmt->fetchColumn();
return $count < LOGIN_MAX_ATTEMPTS;
}
function auth_record_attempt(): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$stmt = db()->prepare('INSERT INTO login_attempts (ip_address) VALUES (?)');
$stmt->execute([$ip]);
}
function auth_user_id(): ?int
{
return $_SESSION['user_id'] ?? null;
}
function auth_display_name(): string
{
return $_SESSION['display_name'] ?? 'Admin';
}

21
core/db.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
/**
* Datenbank-Verbindung (SQLite PDO Singleton)
*/
require_once __DIR__ . '/../config/config.php';
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO('sqlite:' . DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
}
return $pdo;
}

107
core/helpers.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
/**
* Hilfsfunktionen
*/
function e(string $str): string
{
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
function slugify(string $str): string
{
$replacements = [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
];
$str = str_replace(array_keys($replacements), array_values($replacements), $str);
$str = mb_strtolower($str, 'UTF-8');
$str = preg_replace('/[^a-z0-9\s-]/', '', $str);
$str = preg_replace('/[\s-]+/', '-', $str);
return trim($str, '-');
}
function redirect(string $url): never
{
header('Location: ' . $url);
exit;
}
function flash(string $type, string $message): void
{
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
}
function flash_display(): string
{
if (empty($_SESSION['flash'])) {
return '';
}
$html = '';
foreach ($_SESSION['flash'] as $msg) {
$cls = e($msg['type']);
$text = e($msg['message']);
$html .= "<div class=\"flash flash-{$cls}\">{$text}</div>";
}
$_SESSION['flash'] = [];
return $html;
}
function paginate(int $total, int $page, int $perPage): array
{
$totalPages = max(1, (int) ceil($total / $perPage));
$page = max(1, min($page, $totalPages));
$offset = ($page - 1) * $perPage;
return [
'offset' => $offset,
'limit' => $perPage,
'total_pages' => $totalPages,
'current_page' => $page,
'total' => $total,
];
}
function format_date(string $datetime): string
{
$months = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
$ts = strtotime($datetime);
if ($ts === false) {
return $datetime;
}
$day = (int) date('j', $ts);
$month = $months[(int) date('n', $ts)];
$year = date('Y', $ts);
return "{$day}. {$month} {$year}";
}
function excerpt(string $html, int $length = 200): string
{
$text = strip_tags($html);
if (mb_strlen($text) <= $length) {
return $text;
}
$truncated = mb_substr($text, 0, $length);
$lastSpace = mb_strrpos($truncated, ' ');
if ($lastSpace !== false) {
$truncated = mb_substr($truncated, 0, $lastSpace);
}
return $truncated . '…';
}
function sanitize_html(string $html): string
{
$allowed = '<p><br><strong><b><em><i><u><ul><ol><li><h2><h3><h4><a><img><blockquote><pre><code><hr><table><thead><tbody><tr><th><td>';
$html = strip_tags($html, $allowed);
// Event-Handler und javascript:-URLs entfernen
$html = preg_replace('/\bon\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
$html = preg_replace('/\bon\w+\s*=\s*\S+/i', '', $html);
$html = preg_replace('/href\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'href="#"', $html);
$html = preg_replace('/src\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'src=""', $html);
return $html;
}

55
core/upload.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
/**
* Bild-Upload mit Validierung
*/
require_once __DIR__ . '/../config/config.php';
function handle_upload(array $file): string|false
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return false;
}
if ($file['size'] > UPLOAD_MAX_SIZE) {
return false;
}
// MIME-Typ serverseitig prüfen
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ALLOWED_MIME_TYPES, true)) {
return false;
}
// Prüfen ob es ein echtes Bild ist
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
return false;
}
// Dateiendung aus MIME ableiten
$extensions = [
'image/jpeg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
'image/webp' => '.webp',
];
$ext = $extensions[$mime] ?? '.jpg';
// Eindeutigen Dateinamen generieren
$subdir = date('Y/m');
$dir = rtrim(UPLOAD_DIR, '/') . '/' . $subdir;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$filename = bin2hex(random_bytes(16)) . $ext;
$filepath = $dir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
return false;
}
return UPLOAD_URL . $subdir . '/' . $filename;
}

0
data/.gitkeep Normal file
View File

1
data/.htaccess Normal file
View File

@@ -0,0 +1 @@
Deny from all

180
index.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
/**
* Öffentlicher Front-Controller / Router
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/core/db.php';
require_once __DIR__ . '/core/helpers.php';
date_default_timezone_set(TIMEZONE);
$route = trim($_GET['route'] ?? '', '/');
$segments = $route === '' ? [] : explode('/', $route);
// Navigation für alle Seiten laden
$navItems = db()->query('SELECT * FROM navigation ORDER BY sort_order')->fetchAll();
// Navigations-URLs auflösen
foreach ($navItems as &$nav) {
switch ($nav['type']) {
case 'home':
$nav['url'] = '/';
break;
case 'page':
$stmt = db()->prepare('SELECT slug FROM pages WHERE id = ? AND status = "published"');
$stmt->execute([$nav['target']]);
$pg = $stmt->fetchColumn();
$nav['url'] = $pg ? '/seite/' . $pg : '#';
break;
case 'category':
$stmt = db()->prepare('SELECT slug FROM categories WHERE id = ?');
$stmt->execute([$nav['target']]);
$cat = $stmt->fetchColumn();
$nav['url'] = $cat ? '/kategorie/' . $cat : '#';
break;
case 'url':
$nav['url'] = $nav['target'];
break;
default:
$nav['url'] = '#';
}
}
unset($nav);
// Routing
$action = $segments[0] ?? '';
$param = $segments[1] ?? '';
switch ($action) {
case '':
// Startseite
$page = max(1, (int) ($_GET['page'] ?? 1));
$total = (int) db()->query("SELECT COUNT(*) FROM articles WHERE status='published'")->fetchColumn();
$pag = paginate($total, $page, ITEMS_PER_PAGE);
$stmt = db()->prepare(
"SELECT a.*, c.name as category_name, c.slug as category_slug
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
WHERE a.status='published' ORDER BY a.published_at DESC LIMIT ? OFFSET ?"
);
$stmt->execute([$pag['limit'], $pag['offset']]);
$articles = $stmt->fetchAll();
$categories = db()->query('SELECT * FROM categories ORDER BY sort_order, name')->fetchAll();
$pageTitle = SITE_TITLE . ' - ' . SITE_DESCRIPTION;
$metaDescription = 'Blog über IT, Künstliche Intelligenz und Gaming.';
ob_start();
include __DIR__ . '/templates/home.php';
$content = ob_get_clean();
break;
case 'artikel':
if ($param === '') { redirect('/'); }
$stmt = db()->prepare(
"SELECT a.*, c.name as category_name, c.slug as category_slug
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
WHERE a.slug = ? AND a.status='published'"
);
$stmt->execute([$param]);
$article = $stmt->fetch();
if (!$article) {
http_response_code(404);
$pageTitle = 'Nicht gefunden';
ob_start();
include __DIR__ . '/templates/404.php';
$content = ob_get_clean();
break;
}
$pageTitle = $article['title'] . ' - ' . SITE_TITLE;
$metaDescription = $article['excerpt'] ?: excerpt($article['body']);
$ogImage = $article['cover_image'] ?? '';
ob_start();
include __DIR__ . '/templates/article.php';
$content = ob_get_clean();
break;
case 'seite':
if ($param === '') { redirect('/'); }
$stmt = db()->prepare("SELECT * FROM pages WHERE slug = ? AND status='published'");
$stmt->execute([$param]);
$staticPage = $stmt->fetch();
if (!$staticPage) {
http_response_code(404);
$pageTitle = 'Nicht gefunden';
ob_start();
include __DIR__ . '/templates/404.php';
$content = ob_get_clean();
break;
}
$pageTitle = $staticPage['title'] . ' - ' . SITE_TITLE;
ob_start();
include __DIR__ . '/templates/page.php';
$content = ob_get_clean();
break;
case 'kategorie':
if ($param === '') { redirect('/'); }
$stmt = db()->prepare('SELECT * FROM categories WHERE slug = ?');
$stmt->execute([$param]);
$category = $stmt->fetch();
if (!$category) {
http_response_code(404);
$pageTitle = 'Nicht gefunden';
ob_start();
include __DIR__ . '/templates/404.php';
$content = ob_get_clean();
break;
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$totalStmt = db()->prepare("SELECT COUNT(*) FROM articles WHERE status='published' AND category_id=?");
$totalStmt->execute([$category['id']]);
$total = (int) $totalStmt->fetchColumn();
$pag = paginate($total, $page, ITEMS_PER_PAGE);
$stmt = db()->prepare(
"SELECT a.*, c.name as category_name, c.slug as category_slug
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
WHERE a.status='published' AND a.category_id=?
ORDER BY a.published_at DESC LIMIT ? OFFSET ?"
);
$stmt->execute([$category['id'], $pag['limit'], $pag['offset']]);
$articles = $stmt->fetchAll();
$pageTitle = $category['name'] . ' - ' . SITE_TITLE;
$metaDescription = $category['description'];
ob_start();
include __DIR__ . '/templates/category.php';
$content = ob_get_clean();
break;
case 'archiv':
$page = max(1, (int) ($_GET['page'] ?? 1));
$total = (int) db()->query("SELECT COUNT(*) FROM articles WHERE status='published'")->fetchColumn();
$pag = paginate($total, $page, ITEMS_PER_PAGE);
$stmt = db()->prepare(
"SELECT a.*, c.name as category_name, c.slug as category_slug
FROM articles a LEFT JOIN categories c ON a.category_id = c.id
WHERE a.status='published' ORDER BY a.published_at DESC LIMIT ? OFFSET ?"
);
$stmt->execute([$pag['limit'], $pag['offset']]);
$articles = $stmt->fetchAll();
$pageTitle = 'Archiv - ' . SITE_TITLE;
ob_start();
include __DIR__ . '/templates/archive.php';
$content = ob_get_clean();
break;
default:
http_response_code(404);
$pageTitle = 'Nicht gefunden';
ob_start();
include __DIR__ . '/templates/404.php';
$content = ob_get_clean();
}
include __DIR__ . '/templates/layout.php';

194
install.php Normal file
View File

@@ -0,0 +1,194 @@
<?php
/**
* Installations-Script: Erstellt die Datenbank und den Admin-Benutzer.
* Nach der Installation sollte diese Datei gelöscht werden.
*/
require_once __DIR__ . '/config/config.php';
date_default_timezone_set(TIMEZONE);
// Prüfen ob bereits installiert
if (file_exists(DB_PATH)) {
die('Die Datenbank existiert bereits. Lösche diese Datei aus Sicherheitsgründen.');
}
// Datenbank-Verzeichnis prüfen
$dataDir = dirname(DB_PATH);
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
echo "<html><head><meta charset='UTF-8'><title>DasPoschi CMS - Installation</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background: #1a1a2e; color: #e0e0e0; }
h1 { color: #00d4ff; }
form { background: #16213e; padding: 30px; border-radius: 8px; }
label { display: block; margin: 15px 0 5px; font-weight: 600; }
input { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 4px; background: #0f3460; color: #e0e0e0; box-sizing: border-box; }
button { margin-top: 20px; padding: 12px 30px; background: #00d4ff; color: #1a1a2e; border: none; border-radius: 4px; font-size: 16px; font-weight: 600; cursor: pointer; }
button:hover { background: #00b8d9; }
.success { background: #0a3d2a; border: 1px solid #00d474; padding: 20px; border-radius: 8px; margin-top: 20px; }
.error { background: #3d0a0a; border: 1px solid #d40000; padding: 20px; border-radius: 8px; margin-top: 20px; }
</style></head><body>";
echo "<h1>DasPoschi CMS - Installation</h1>";
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$displayName = trim($_POST['display_name'] ?? '');
$password = $_POST['password'] ?? '';
$passwordConfirm = $_POST['password_confirm'] ?? '';
$errors = [];
if (strlen($username) < 3) {
$errors[] = 'Benutzername muss mindestens 3 Zeichen lang sein.';
}
if (strlen($password) < 10) {
$errors[] = 'Passwort muss mindestens 10 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Passwörter stimmen nicht überein.';
}
if (empty($displayName)) {
$displayName = $username;
}
if (!empty($errors)) {
echo "<div class='error'><ul>";
foreach ($errors as $err) {
echo "<li>" . htmlspecialchars($err) . "</li>";
}
echo "</ul></div>";
} else {
try {
$pdo = new PDO('sqlite:' . DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
// Tabellen erstellen
$pdo->exec("
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
");
$pdo->exec("
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
)
");
$pdo->exec("
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
cover_image TEXT DEFAULT NULL,
category_id INTEGER DEFAULT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','published')),
published_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
)
");
$pdo->exec("
CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','published')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
");
$pdo->exec("
CREATE TABLE navigation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('url','page','category','home')),
target TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER DEFAULT NULL,
FOREIGN KEY (parent_id) REFERENCES navigation(id) ON DELETE CASCADE
)
");
$pdo->exec("
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
)
");
$pdo->exec("CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address, attempted_at)");
// Admin-Benutzer anlegen
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)');
$stmt->execute([$username, $hash, $displayName]);
// Standard-Navigation
$pdo->exec("INSERT INTO navigation (label, type, target, sort_order) VALUES ('Startseite', 'home', '', 0)");
// Standard-Kategorien
$pdo->exec("INSERT INTO categories (name, slug, description, sort_order) VALUES ('IT', 'it', 'Technik und Programmierung', 0)");
$pdo->exec("INSERT INTO categories (name, slug, description, sort_order) VALUES ('KI', 'ki', 'Künstliche Intelligenz', 1)");
$pdo->exec("INSERT INTO categories (name, slug, description, sort_order) VALUES ('Gaming', 'gaming', 'Spiele und Gaming-Kultur', 2)");
echo "<div class='success'>";
echo "<h2>Installation erfolgreich!</h2>";
echo "<p>Der Admin-Account wurde erstellt. Du kannst dich jetzt anmelden:</p>";
echo "<p><a href='/admin/login.php' style='color: #00d4ff;'>Zum Admin-Login &rarr;</a></p>";
echo "<p><strong>Wichtig:</strong> Lösche diese Datei (install.php) nach der Installation!</p>";
echo "</div>";
echo "</body></html>";
exit;
} catch (Exception $ex) {
// DB-Datei wieder löschen bei Fehler
if (file_exists(DB_PATH)) {
unlink(DB_PATH);
}
echo "<div class='error'>Fehler bei der Installation: " . htmlspecialchars($ex->getMessage()) . "</div>";
}
}
}
echo "
<form method='post'>
<label for='username'>Benutzername</label>
<input type='text' id='username' name='username' required minlength='3'
value='" . htmlspecialchars($_POST['username'] ?? '') . "'>
<label for='display_name'>Anzeigename (optional)</label>
<input type='text' id='display_name' name='display_name'
value='" . htmlspecialchars($_POST['display_name'] ?? '') . "'>
<label for='password'>Passwort (mind. 10 Zeichen)</label>
<input type='password' id='password' name='password' required minlength='10'>
<label for='password_confirm'>Passwort bestätigen</label>
<input type='password' id='password_confirm' name='password_confirm' required>
<button type='submit'>Installieren</button>
</form>";
echo "</body></html>";

5
templates/404.php Normal file
View File

@@ -0,0 +1,5 @@
<div class="error-page">
<h1>404</h1>
<p>Die angeforderte Seite wurde nicht gefunden.</p>
<a href="/" class="btn">&larr; Zurück zur Startseite</a>
</div>

View File

@@ -0,0 +1,22 @@
<article class="article-card">
<?php if (!empty($article['cover_image'])): ?>
<a href="/artikel/<?= e($article['slug']) ?>" class="card-image">
<img src="<?= e($article['cover_image']) ?>" alt="<?= e($article['title']) ?>" loading="lazy">
</a>
<?php endif; ?>
<div class="card-body">
<?php if (!empty($article['category_name'])): ?>
<a href="/kategorie/<?= e($article['category_slug']) ?>" class="card-category"><?= e($article['category_name']) ?></a>
<?php endif; ?>
<h2 class="card-title">
<a href="/artikel/<?= e($article['slug']) ?>"><?= e($article['title']) ?></a>
</h2>
<p class="card-excerpt">
<?= e($article['excerpt'] ?: excerpt($article['body'])) ?>
</p>
<div class="card-meta">
<time><?= format_date($article['published_at'] ?? $article['created_at']) ?></time>
<a href="/artikel/<?= e($article['slug']) ?>" class="read-more">Weiterlesen &rarr;</a>
</div>
</div>
</article>

5
templates/_footer.php Normal file
View File

@@ -0,0 +1,5 @@
<footer class="site-footer">
<div class="container">
<p>&copy; <?= date('Y') ?> <?= e(SITE_TITLE) ?>. Alle Rechte vorbehalten.</p>
</div>
</footer>

11
templates/_header.php Normal file
View File

@@ -0,0 +1,11 @@
<header class="site-header">
<div class="container header-inner">
<a href="/" class="site-logo"><?= e(SITE_TITLE) ?></a>
<button class="menu-toggle" id="menuToggle" aria-label="Menü">&#9776;</button>
<nav class="site-nav" id="siteNav">
<?php foreach ($navItems as $nav): ?>
<a href="<?= e($nav['url']) ?>"><?= e($nav['label']) ?></a>
<?php endforeach; ?>
</nav>
</div>
</header>

15
templates/_pagination.php Normal file
View File

@@ -0,0 +1,15 @@
<?php if ($pag['total_pages'] > 1): ?>
<nav class="pagination">
<?php if ($pag['current_page'] > 1): ?>
<a href="?page=<?= $pag['current_page'] - 1 ?>">&laquo; Zurück</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $pag['total_pages']; $i++): ?>
<a href="?page=<?= $i ?>" class="<?= $i === $pag['current_page'] ? 'active' : '' ?>"><?= $i ?></a>
<?php endfor; ?>
<?php if ($pag['current_page'] < $pag['total_pages']): ?>
<a href="?page=<?= $pag['current_page'] + 1 ?>">Weiter &raquo;</a>
<?php endif; ?>
</nav>
<?php endif; ?>

16
templates/archive.php Normal file
View File

@@ -0,0 +1,16 @@
<header class="page-header">
<h1>Archiv</h1>
<p class="page-description">Alle veröffentlichten Artikel</p>
</header>
<?php if (empty($articles)): ?>
<p class="empty-state">Noch keine Artikel veröffentlicht.</p>
<?php else: ?>
<div class="article-grid">
<?php foreach ($articles as $article): ?>
<?php include __DIR__ . '/_article-card.php'; ?>
<?php endforeach; ?>
</div>
<?php include __DIR__ . '/_pagination.php'; ?>
<?php endif; ?>

23
templates/article.php Normal file
View File

@@ -0,0 +1,23 @@
<article class="article-single">
<?php if (!empty($article['cover_image'])): ?>
<div class="article-cover">
<img src="<?= e($article['cover_image']) ?>" alt="<?= e($article['title']) ?>">
</div>
<?php endif; ?>
<header class="article-header">
<?php if (!empty($article['category_name'])): ?>
<a href="/kategorie/<?= e($article['category_slug']) ?>" class="article-category"><?= e($article['category_name']) ?></a>
<?php endif; ?>
<h1><?= e($article['title']) ?></h1>
<time class="article-date"><?= format_date($article['published_at'] ?? $article['created_at']) ?></time>
</header>
<div class="article-body prose">
<?= $article['body'] ?>
</div>
<footer class="article-footer">
<a href="/" class="back-link">&larr; Zurück zur Übersicht</a>
</footer>
</article>

18
templates/category.php Normal file
View File

@@ -0,0 +1,18 @@
<header class="page-header">
<h1>Kategorie: <?= e($category['name']) ?></h1>
<?php if ($category['description']): ?>
<p class="page-description"><?= e($category['description']) ?></p>
<?php endif; ?>
</header>
<?php if (empty($articles)): ?>
<p class="empty-state">Keine Artikel in dieser Kategorie.</p>
<?php else: ?>
<div class="article-grid">
<?php foreach ($articles as $article): ?>
<?php include __DIR__ . '/_article-card.php'; ?>
<?php endforeach; ?>
</div>
<?php include __DIR__ . '/_pagination.php'; ?>
<?php endif; ?>

25
templates/home.php Normal file
View File

@@ -0,0 +1,25 @@
<section class="hero">
<h1><?= e(SITE_TITLE) ?></h1>
<p class="hero-subtitle"><?= e(SITE_DESCRIPTION) ?></p>
</section>
<?php if (!empty($categories)): ?>
<div class="category-bar">
<?php foreach ($categories as $cat): ?>
<a href="/kategorie/<?= e($cat['slug']) ?>" class="category-tag"><?= e($cat['name']) ?></a>
<?php endforeach; ?>
<a href="/archiv" class="category-tag">Archiv</a>
</div>
<?php endif; ?>
<?php if (empty($articles)): ?>
<p class="empty-state">Noch keine Artikel veröffentlicht.</p>
<?php else: ?>
<div class="article-grid">
<?php foreach ($articles as $article): ?>
<?php include __DIR__ . '/_article-card.php'; ?>
<?php endforeach; ?>
</div>
<?php include __DIR__ . '/_pagination.php'; ?>
<?php endif; ?>

28
templates/layout.php Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle ?? SITE_TITLE) ?></title>
<meta name="description" content="<?= e($metaDescription ?? SITE_DESCRIPTION) ?>">
<?php if (!empty($ogImage)): ?>
<meta property="og:image" content="<?= e($ogImage) ?>">
<?php endif; ?>
<meta property="og:title" content="<?= e($pageTitle ?? SITE_TITLE) ?>">
<meta property="og:type" content="website">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<?php include __DIR__ . '/_header.php'; ?>
<main class="site-main">
<div class="container">
<?= $content ?? '' ?>
</div>
</main>
<?php include __DIR__ . '/_footer.php'; ?>
<script src="/assets/js/main.js"></script>
</body>
</html>

6
templates/page.php Normal file
View File

@@ -0,0 +1,6 @@
<article class="static-page">
<h1><?= e($staticPage['title']) ?></h1>
<div class="prose">
<?= $staticPage['body'] ?>
</div>
</article>

0
uploads/.gitkeep Normal file
View File

12
uploads/.htaccess Normal file
View File

@@ -0,0 +1,12 @@
# Nur Bilder ausliefern, kein PHP
<FilesMatch "\.php$">
Deny from all
</FilesMatch>
# Nur erlaubte Dateitypen
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
Allow from all
</FilesMatch>
# Kein Directory Listing
Options -Indexes