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