v2.5.1: Admin theme refactor, Navigation plugin, user roles, guide restructure
- Reorganize admin into admin/theme/default/ (views + assets) - Rename GuideNav to Navigation plugin (essential, protected) - Plugin assets support (SCSS/CSS) loaded after theme CSS - User roles: Admin, Content Manager, BI Manager, Site Admin - Role-based access control (RBAC) for admin routes and sidebar - Guide restructure: sub-topics in separate folders with sidebar nav - Dynamic breadcrumb for homepage and subdirectories - Fix theme path traversal (../../ -> ../) in admin.php - Fix CodeMirror mode load order (xml -> css -> js -> htmlmixed -> php) - Fix editor-toolbar.js null checks for plugin edit pages - Layout select from theme.json with live frontmatter update - Footer sticky at bottom of viewport (min-height: 100vh) - Breadcrumb color fix (var(--nav-font) -> var(--header-bg)) - Remove language switcher from guide pages - Update README.md and README.en.md - Bump version to 2.5.1
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password_hash": "$2y$12$nUpoaCNZZFL8kOTHNC85q.dTy0hWRmPoF3dAn4GcvSXERMioYr5b6",
|
||||
"role": "admin",
|
||||
"created": "2025-01-01"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"session_timeout": 1800,
|
||||
"max_login_attempts": 5,
|
||||
"lockout_duration": 900
|
||||
}
|
||||
}
|
||||
+92
-5
@@ -9,6 +9,28 @@ class AdminAuth
|
||||
private array $adminConfig;
|
||||
private string $lockFile;
|
||||
|
||||
/**
|
||||
* Role definitions with permissions.
|
||||
* Each role maps to a list of allowed route prefixes.
|
||||
* 'admin' has wildcard '*' access.
|
||||
*/
|
||||
public const ROLE_PERMISSIONS = [
|
||||
'admin' => ['*'],
|
||||
'content-manager' => ['dashboard', 'content', 'content-edit', 'content-new', 'content-delete', 'content-dir-create', 'content-dir-rename', 'content-dir-delete', 'content-move', 'guide', 'logout'],
|
||||
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
||||
'site-admin' => ['dashboard', 'theme', 'theme-new', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable role labels.
|
||||
*/
|
||||
public const ROLE_LABELS = [
|
||||
'admin' => 'Admin',
|
||||
'content-manager' => 'Content Beheerder',
|
||||
'bi-manager' => 'BI Beheerder',
|
||||
'site-admin' => ' Site Admin',
|
||||
];
|
||||
|
||||
public function __construct(array $appConfig)
|
||||
{
|
||||
$this->config = $appConfig;
|
||||
@@ -154,6 +176,45 @@ class AdminAuth
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role of the current user.
|
||||
*/
|
||||
public function getCurrentRole(): string
|
||||
{
|
||||
return $_SESSION['admin_role'] ?? 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has permission to access a route.
|
||||
*/
|
||||
public function hasPermission(string $route): bool
|
||||
{
|
||||
$role = $this->getCurrentRole();
|
||||
$permissions = self::ROLE_PERMISSIONS[$role] ?? ['dashboard', 'logout'];
|
||||
|
||||
if (in_array('*', $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($route, $permissions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles.
|
||||
*/
|
||||
public static function getRoles(): array
|
||||
{
|
||||
return self::ROLE_LABELS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role label.
|
||||
*/
|
||||
public static function getRoleLabel(string $role): string
|
||||
{
|
||||
return self::ROLE_LABELS[$role] ?? $role;
|
||||
}
|
||||
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['admin_csrf_token'])) {
|
||||
@@ -176,13 +237,17 @@ class AdminAuth
|
||||
|
||||
public function getUsers(): array
|
||||
{
|
||||
return array_map(function ($u) {
|
||||
return [
|
||||
$users = [];
|
||||
foreach ($this->adminConfig['users'] ?? [] as $u) {
|
||||
$role = $u['role'] ?? 'admin';
|
||||
$users[$u['username']] = [
|
||||
'username' => $u['username'],
|
||||
'role' => $u['role'] ?? 'admin',
|
||||
'role' => $role,
|
||||
'role_label' => self::getRoleLabel($role),
|
||||
'created' => $u['created'] ?? ''
|
||||
];
|
||||
}, $this->adminConfig['users'] ?? []);
|
||||
}
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function addUser(string $username, string $password, string $role = 'admin'): array
|
||||
@@ -193,6 +258,9 @@ class AdminAuth
|
||||
if (strlen($password) < 8) {
|
||||
return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
|
||||
}
|
||||
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||
}
|
||||
|
||||
$this->adminConfig['users'][] = [
|
||||
'username' => $username,
|
||||
@@ -201,10 +269,29 @@ class AdminAuth
|
||||
'created' => date('Y-m-d')
|
||||
];
|
||||
$this->saveAdminConfig();
|
||||
$this->log('info', "Gebruiker aangemaakt: {$username}");
|
||||
$this->log('info', "Gebruiker aangemaakt: {$username} (rol: {$role})");
|
||||
return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the role of an existing user.
|
||||
*/
|
||||
public function changeRole(string $username, string $role): array
|
||||
{
|
||||
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||
}
|
||||
foreach ($this->adminConfig['users'] as &$user) {
|
||||
if ($user['username'] === $username) {
|
||||
$user['role'] = $role;
|
||||
$this->saveAdminConfig();
|
||||
$this->log('info', "Rol gewijzigd: {$username} -> {$role}");
|
||||
return ['success' => true, 'message' => 'Rol gewijzigd.'];
|
||||
}
|
||||
}
|
||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||
}
|
||||
|
||||
public function deleteUser(string $username): array
|
||||
{
|
||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
<?php
|
||||
// Load theme sidebar color from site config
|
||||
$layoutConfigFile = __DIR__ . '/../../config.json';
|
||||
$layoutSiteConfig = file_exists($layoutConfigFile) ? json_decode(file_get_contents($layoutConfigFile), true) : [];
|
||||
$layoutActiveTheme = $layoutSiteConfig['active_theme'] ?? 'default';
|
||||
$layoutThemeDir = dirname(__DIR__, 1) . '/../themes/' . $layoutActiveTheme;
|
||||
$layoutThemeFile = $layoutThemeDir . '/theme.json';
|
||||
$layoutThemeConfig = file_exists($layoutThemeFile) ? json_decode(file_get_contents($layoutThemeFile), true) : [];
|
||||
$layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
|
||||
?><!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CodePress Admin</title>
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||
.admin-sidebar { background-color: <?= htmlspecialchars($layoutSidebarColor) ?>; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; }
|
||||
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
||||
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
||||
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
||||
.admin-sidebar .nav-link i { width: 24px; text-align: center; margin-right: 0.5rem; }
|
||||
.admin-sidebar .nav-section { color: rgba(255,255,255,0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; padding: 1rem 1.25rem 0.3rem 1.25rem; }
|
||||
.admin-main { margin-left: 240px; padding: 2rem; }
|
||||
.admin-brand { color: #fff; padding: 1.25rem; font-size: 1.1rem; border-bottom: 1px solid rgba(255,255,255,0.15); }
|
||||
.admin-brand i { margin-right: 0.5rem; }
|
||||
.stat-card { border: none; border-radius: 0.5rem; }
|
||||
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
||||
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); position: absolute; bottom: 0; width: 100%; }
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
||||
.admin-main { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<?php if (in_array($route ?? '', ['content-edit', 'content-new', 'plugins-edit'])): ?>
|
||||
<link rel="stylesheet" href="/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/editor.css">
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<nav class="admin-sidebar d-flex flex-column">
|
||||
<div class="admin-brand">
|
||||
<i class="bi bi-gear-fill"></i> CodePress Admin
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2">
|
||||
<li class="nav-section">Algemeen</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'dashboard' || ($route ?? '') === '' ? 'active' : '' ?>" href="/admin/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">Content</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'content' || str_starts_with($route ?? '', 'content') ? 'active' : '' ?>" href="/admin/content">
|
||||
<i class="bi bi-file-earmark-text"></i> Content
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">Instellingen</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'config' ? 'active' : '' ?>" href="/admin/config">
|
||||
<i class="bi bi-sliders"></i> Configuratie
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'theme' ? 'active' : '' ?>" href="/admin/theme">
|
||||
<i class="bi bi-palette"></i> Thema
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'security' ? 'active' : '' ?>" href="/admin/security">
|
||||
<i class="bi bi-shield-check"></i> Beveiliging
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">Gegevens</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'statistics' ? 'active' : '' ?>" href="/admin/statistics">
|
||||
<i class="bi bi-bar-chart"></i> Statistieken
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'logs' ? 'active' : '' ?>" href="/admin/logs">
|
||||
<i class="bi bi-journal-text"></i> Logs
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">Systeem</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="/admin/plugins">
|
||||
<i class="bi bi-plug"></i> Plugins
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'users' ? 'active' : '' ?>" href="/admin/users">
|
||||
<i class="bi bi-people"></i> Gebruikers
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'update' ? 'active' : '' ?>" href="/admin/update">
|
||||
<i class="bi bi-cloud-arrow-down"></i> Update
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">Help</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= ($route ?? '') === 'guide' ? 'active' : '' ?>" href="/admin/guide">
|
||||
<i class="bi bi-book"></i> Handleiding
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section mt-3">Links</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/" target="_blank">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Website bekijken
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-warning" href="/admin/logout">
|
||||
<i class="bi bi-box-arrow-left"></i> Uitloggen
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="admin-user">
|
||||
<i class="bi bi-person-circle"></i> <?= htmlspecialchars($user['username'] ?? '') ?>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="admin-main">
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-<?= $messageType ?? 'info' ?> alert-dismissible fade show" role="alert">
|
||||
<?= htmlspecialchars($message) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$currentRoute = $route ?? 'dashboard';
|
||||
switch ($currentRoute) {
|
||||
case 'dashboard':
|
||||
case '':
|
||||
require __DIR__ . '/pages/dashboard.php';
|
||||
break;
|
||||
case 'content':
|
||||
require __DIR__ . '/pages/content.php';
|
||||
break;
|
||||
case 'content-edit':
|
||||
require __DIR__ . '/pages/content-edit.php';
|
||||
break;
|
||||
case 'content-new':
|
||||
require __DIR__ . '/pages/content-new.php';
|
||||
break;
|
||||
case 'config':
|
||||
require __DIR__ . '/pages/config.php';
|
||||
break;
|
||||
case 'security':
|
||||
require __DIR__ . '/pages/security.php';
|
||||
break;
|
||||
case 'statistics':
|
||||
require __DIR__ . '/pages/statistics.php';
|
||||
break;
|
||||
# Media route (removed from menu)
|
||||
// Uncomment if media functionality is needed elsewhere
|
||||
// require __DIR__ . '/pages/media.php';
|
||||
break;
|
||||
case 'theme':
|
||||
require __DIR__ . '/pages/theme.php';
|
||||
break;
|
||||
|
||||
case 'theme-new':
|
||||
require __DIR__ . '/pages/theme-new.php';
|
||||
break;
|
||||
|
||||
case 'plugins':
|
||||
require __DIR__ . '/pages/plugins.php';
|
||||
break;
|
||||
|
||||
case 'plugins-new':
|
||||
require __DIR__ . '/pages/plugins-new.php';
|
||||
break;
|
||||
|
||||
case 'plugins-edit':
|
||||
require __DIR__ . '/pages/plugins-edit.php';
|
||||
break;
|
||||
|
||||
case 'plugins-config':
|
||||
require __DIR__ . '/pages/plugin-config.php';
|
||||
break;
|
||||
|
||||
case 'content-dir-rename':
|
||||
require __DIR__ . '/pages/content-dir-form.php';
|
||||
break;
|
||||
case 'content-move':
|
||||
require __DIR__ . '/pages/content-move-form.php';
|
||||
break;
|
||||
case 'users':
|
||||
require __DIR__ . '/pages/users.php';
|
||||
break;
|
||||
case 'guide':
|
||||
require __DIR__ . '/pages/guide.php';
|
||||
break;
|
||||
case 'logs':
|
||||
require __DIR__ . '/pages/logs.php';
|
||||
break;
|
||||
case 'update':
|
||||
require __DIR__ . '/pages/update.php';
|
||||
break;
|
||||
}
|
||||
?>
|
||||
</main>
|
||||
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<?php if (in_array($route ?? '', ['content-edit', 'content-new', 'plugins-edit'])): ?>
|
||||
<script src="/assets/codemirror/codemirror.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/markdown.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/xml.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/htmlmixed.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/php.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/clike.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/css.min.js"></script>
|
||||
<script src="/assets/codemirror/mode/javascript.min.js"></script>
|
||||
<script src="/assets/codemirror/addon/edit/closebrackets.min.js"></script>
|
||||
<script src="/assets/codemirror/addon/selection/active-line.min.js"></script>
|
||||
<script src="/assets/js/editor-toolbar.js"></script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,224 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
|
||||
|
||||
<form method="POST" action="/admin/config">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-globe"></i> Algemene instellingen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="site_title" class="form-label">Site titel</label>
|
||||
<input type="text" class="form-control" id="site_title" name="site_title"
|
||||
value="<?= htmlspecialchars($configData['site_title'] ?? 'CodePress') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="default_page" class="form-label">Standaard/startpagina</label>
|
||||
<select name="default_page" id="default_page" class="form-select">
|
||||
<option value="auto" <?= ($configData['default_page'] ?? 'auto') === 'auto' ? 'selected' : '' ?>>Auto (eerste beschikbare pagina)</option>
|
||||
<option value="newest" <?= ($configData['default_page'] ?? '') === 'newest' ? 'selected' : '' ?>>Nieuwste (laatste gewijzigde pagina)</option>
|
||||
<?php foreach ($availablePages as $page): ?>
|
||||
<option value="<?= htmlspecialchars($page) ?>" <?= ($configData['default_page'] ?? '') === $page ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars(ucwords(str_replace(['-', '_'], ' ', $page))) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="form-text text-muted">Welke pagina wordt getoond als bezoekers de site openen.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-translate"></i> Taal
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="language_default" class="form-label">Standaard taal</label>
|
||||
<select name="language_default" id="language_default" class="form-select">
|
||||
<?php foreach (['nl' => 'Nederlands', 'en' => 'English'] as $code => $label): ?>
|
||||
<option value="<?= $code ?>" <?= ($configData['language']['default'] ?? 'nl') === $code ? 'selected' : '' ?>>
|
||||
<?= $label ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Beschikbare talen</label>
|
||||
<div>
|
||||
<?php $availLangs = $configData['language']['available'] ?? ['nl', 'en']; ?>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="lang_nl" name="language_available[]" value="nl" <?= in_array('nl', $availLangs) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="lang_nl">Nederlands</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="lang_en" name="language_available[]" value="en" <?= in_array('en', $availLangs) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="lang_en">English</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-search"></i> SEO
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="seo_description" class="form-label">Meta beschrijving</label>
|
||||
<textarea class="form-control" id="seo_description" name="seo_description" rows="2"><?= htmlspecialchars($configData['seo']['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="seo_keywords" class="form-label">Meta keywords</label>
|
||||
<input type="text" class="form-control" id="seo_keywords" name="seo_keywords"
|
||||
value="<?= htmlspecialchars($configData['seo']['keywords'] ?? '') ?>">
|
||||
<small class="form-text text-muted">Komma-gescheiden trefwoorden.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person"></i> Auteur
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="author_name" class="form-label">Naam</label>
|
||||
<input type="text" class="form-control" id="author_name" name="author_name"
|
||||
value="<?= htmlspecialchars($configData['author']['name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="author_website" class="form-label">Website URL</label>
|
||||
<input type="text" class="form-control" id="author_website" name="author_website"
|
||||
value="<?= htmlspecialchars($configData['author']['website'] ?? '') ?>" placeholder="bijv. noorlander.info">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-toggle-on"></i> Features
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="feature_auto_link" name="feature_auto_link" value="1" <?= !empty($configData['features']['auto_link_pages']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="feature_auto_link">Automatisch pagina's linken</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="feature_search" name="feature_search" value="1" <?= !empty($configData['features']['search_enabled']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="feature_search">Zoekfunctie inschakelen</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="feature_breadcrumbs" name="feature_breadcrumbs" value="1" <?= !empty($configData['features']['breadcrumbs_enabled']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="feature_breadcrumbs">Breadcrumbs tonen</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="show_version" name="show_version" value="1" <?= !empty($configData['show_version']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="show_version">CMS versie tonen in footer</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-eye-slash"></i> IP Uitsluitingen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="excluded_ips" class="form-label fw-bold">IP-adressen uitsluiten van statistieken en beveiliging</label>
|
||||
<textarea class="form-control font-monospace" id="excluded_ips" name="excluded_ips" rows="4" placeholder="Één IP per regel (bijv. 127.0.0.1)"><?= htmlspecialchars(implode("\n", $configData['analytics']['excluded_ips'] ?? [])) ?></textarea>
|
||||
<div class="form-text">Verzoeken van deze IP-adressen worden niet opgenomen in de statistieken en overgeslagen bij beveiligingscontroles (bot-detectie, rate limiting).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-journal-text"></i> Logging
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php $logging = $configData['logging'] ?? []; ?>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="logging_enabled" name="logging_enabled" value="1" <?= !empty($logging['enabled']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="logging_enabled">Logging inschakelen</label>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="logging_driver" class="form-label">Opslag</label>
|
||||
<select class="form-select" id="logging_driver" name="logging_driver">
|
||||
<option value="sqlite" <?= ($logging['driver'] ?? 'sqlite') === 'sqlite' ? 'selected' : '' ?>>SQLite (standaard)</option>
|
||||
<option value="syslog" <?= ($logging['driver'] ?? '') === 'syslog' ? 'selected' : '' ?>>Syslog</option>
|
||||
</select>
|
||||
<div class="form-text">Kies <strong>Syslog</strong> om logregels naar een externe syslog-server te sturen. Logregels worden altijd ook lokaal opgeslagen.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="syslog_host" class="form-label">Syslog server (host)</label>
|
||||
<input type="text" class="form-control font-monospace" id="syslog_host" name="syslog_host"
|
||||
value="<?= htmlspecialchars($logging['syslog_host'] ?? '') ?>" placeholder="bijv. 192.168.1.10">
|
||||
<div class="form-text">Vul de host van de syslog-server in (bijv. <code>192.168.1.10</code> of <code>logs.example.com</code>).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="syslog_port" class="form-label">Poort</label>
|
||||
<input type="number" class="form-control" id="syslog_port" name="syslog_port" min="1" max="65535"
|
||||
value="<?= (int)($logging['syslog_port'] ?? 514) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="syslog_facility" class="form-label">Facility</label>
|
||||
<select class="form-select" id="syslog_facility" name="syslog_facility">
|
||||
<?php foreach (['local0', 'local1', 'local2', 'local3', 'local4', 'local5', 'local6', 'local7', 'daemon', 'user', 'auth'] as $fac): ?>
|
||||
<option value="<?= $fac ?>" <?= ($logging['syslog_facility'] ?? 'local0') === $fac ? 'selected' : '' ?>><?= $fac ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Categorie van de logbron (bijv. <code>local0</code>–<code>local7</code> voor eigen applicaties).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="syslog_ident" class="form-label">Syslog ident</label>
|
||||
<input type="text" class="form-control font-monospace" id="syslog_ident" name="syslog_ident"
|
||||
value="<?= htmlspecialchars($logging['syslog_ident'] ?? 'codepress') ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Te registreren gebeurtenissen</label>
|
||||
<?php $events = $logging['events'] ?? []; ?>
|
||||
<div class="row">
|
||||
<?php
|
||||
$eventLabels = [
|
||||
'admin' => 'Admin activiteiten',
|
||||
'requests' => 'Requests (pagina bezoeken)',
|
||||
'errors' => 'Fouten & waarschuwingen',
|
||||
'security' => 'Beveiliging',
|
||||
'content' => 'Content wijzigingen',
|
||||
'system' => 'Systeem',
|
||||
];
|
||||
foreach ($eventLabels as $key => $label):
|
||||
?>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="logging_event_<?= $key ?>" name="logging_events[]" value="<?= $key ?>" <?= !empty($events[$key]) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="logging_event_<?= $key ?>"><?= $label ?></label>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-lg"></i> Configuratie opslaan
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,26 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> Map hernoemen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Huidige mapnaam</label>
|
||||
<p class="form-control-plaintext fw-bold"><?= htmlspecialchars(basename($fullPath)) ?></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_name" class="form-label">Nieuwe naam</label>
|
||||
<input type="text" class="form-control" id="new_name" name="new_name"
|
||||
value="<?= htmlspecialchars(basename($fullPath)) ?>" required autofocus>
|
||||
<div class="form-text">Alleen letters, cijfers, punten, underscores en streepjes.</div>
|
||||
</div>
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<a href="/admin/content?dir=<?= urlencode(dirname($dir) === '.' ? '' : dirname($dir)) ?>" class="btn btn-secondary">Annuleren</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Opslaan</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,320 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> <?= htmlspecialchars($fileName) ?></h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-edit?file=<?= urlencode($file) ?>" id="editor-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="filename" name="filename"
|
||||
value="<?= htmlspecialchars(pathinfo($fileName, PATHINFO_FILENAME)) ?>" required>
|
||||
<span class="input-group-text">.<?= htmlspecialchars($fileExt) ?></span>
|
||||
</div>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
||||
</div>
|
||||
<?php if ($isEditable): ?>
|
||||
<div class="col-md-4">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout</label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
<?php if (empty($themeLayouts)): ?>
|
||||
<option value="sidebar-content" <?= $currentLayout === 'sidebar-content' ? 'selected' : '' ?>>Sidebar + Inhoud (standaard)</option>
|
||||
<option value="content" <?= $currentLayout === 'content' ? 'selected' : '' ?>>Alleen inhoud (full-width)</option>
|
||||
<option value="sidebar" <?= $currentLayout === 'sidebar' ? 'selected' : '' ?>>Alleen sidebar (full-width)</option>
|
||||
<option value="content-sidebar" <?= $currentLayout === 'content-sidebar' ? 'selected' : '' ?>>Inhoud links + sidebar rechts</option>
|
||||
<option value="content-sidebar-reverse" <?= $currentLayout === 'content-sidebar-reverse' ? 'selected' : '' ?>>Inhoud rechts + sidebar links</option>
|
||||
<?php else: ?>
|
||||
<?php foreach ($themeLayouts as $key => $file): ?>
|
||||
<option value="<?= htmlspecialchars($key) ?>" <?= $currentLayout === $key ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $key))) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php if (!empty($availablePlugins)): ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label d-block">Zichtbare plugins</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<?php foreach ($availablePlugins as $plugin): ?>
|
||||
<input type="checkbox" class="btn-check" id="plugin-<?= $plugin ?>" name="plugins[]" value="<?= htmlspecialchars($plugin) ?>" autocomplete="off" <?= in_array($plugin, $selectedPlugins) ? 'checked' : '' ?>>
|
||||
<label class="btn btn-outline-primary btn-sm" for="plugin-<?= $plugin ?>"><?= htmlspecialchars($plugin) ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($isEditable): ?>
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="<?= $fileExt ?>"><?= htmlspecialchars($fileContent) ?></textarea>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" title="Opslaan (Ctrl+S)">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<?php if ($isEditable): ?>
|
||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars(pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME)) ?>" target="_blank" class="btn btn-outline-info" title="Open in nieuw tabblad">
|
||||
<i class="bi bi-eye"></i> Preview
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="/admin/content?dir=<?= urlencode(dirname($file)) ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var backBtn = document.getElementById('back-btn');
|
||||
var filenameInput = document.getElementById('filename');
|
||||
if (!backBtn) return;
|
||||
|
||||
var changed = false;
|
||||
|
||||
function markChanged() {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
||||
backBtn.classList.remove('btn-outline-secondary');
|
||||
backBtn.classList.add('btn-outline-danger');
|
||||
}
|
||||
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', markChanged);
|
||||
}
|
||||
|
||||
window.__onContentChange = markChanged;
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php if ($isEditable): ?>
|
||||
<!-- Media Modal -->
|
||||
<div class="modal fade" id="mediaModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-images"></i> Media</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="collapse mb-3" id="mediaUploadForm">
|
||||
<div class="card card-body">
|
||||
<form id="media-upload-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="mb-2">
|
||||
<input type="file" class="form-control" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,audio/mpeg,audio/wav" id="media-file-input">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-sm" id="media-upload-btn" disabled>
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#mediaUploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
<small class="text-muted" id="media-count"></small>
|
||||
</div>
|
||||
|
||||
<!-- Media grid -->
|
||||
<div id="media-grid" class="row g-2">
|
||||
<div class="col-12 text-center text-muted py-4">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div> Laden...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size form (shown when clicking an image) -->
|
||||
<div id="media-size-form" class="d-none">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<img id="size-preview" src="" alt="" style="width:80px;height:60px;object-fit:cover;border-radius:4px;">
|
||||
<div>
|
||||
<strong id="size-filename" class="d-block"></strong>
|
||||
<small class="text-muted">Geef de gewenste afmetingen (optioneel)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Breedte (px)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="size-width" placeholder="auto" min="1">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Hoogte (px)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="size-height" placeholder="auto" min="1">
|
||||
</div>
|
||||
<div class="col-4 d-flex align-items-end gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="size-insert-btn">
|
||||
<i class="bi bi-check-lg"></i> Invoegen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="size-cancel-btn">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var mediaModal = document.getElementById('mediaModal');
|
||||
if (!mediaModal) return;
|
||||
|
||||
var ext = document.getElementById('editor-textarea').dataset.ext || 'md';
|
||||
var mode = ext === 'md' ? 'markdown' : 'html';
|
||||
var pendingFile = null;
|
||||
|
||||
function getCurrentMode() {
|
||||
var ta = document.getElementById('editor-textarea');
|
||||
if (!ta) return 'html';
|
||||
var ext = ta.dataset.ext || 'md';
|
||||
return ext === 'md' ? 'markdown' : 'html';
|
||||
}
|
||||
|
||||
mediaModal.addEventListener('show.bs.modal', function () {
|
||||
document.getElementById('media-size-form').classList.add('d-none');
|
||||
document.getElementById('media-grid').classList.remove('d-none');
|
||||
pendingFile = null;
|
||||
fetch('/admin/media-list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (files) {
|
||||
var grid = document.getElementById('media-grid');
|
||||
document.getElementById('media-count').textContent = files.length + ' bestand(en)';
|
||||
if (files.length === 0) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">Geen media bestanden gevonden.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
files.forEach(function (f) {
|
||||
var col = document.createElement('div');
|
||||
col.className = 'col-6 col-md-4 col-lg-3';
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card card-media-item';
|
||||
card.style.cursor = 'pointer';
|
||||
card.title = 'Klik om in te voegen';
|
||||
|
||||
var preview;
|
||||
if (f.is_image) {
|
||||
preview = '<img src="' + f.url + '" alt="' + f.name + '" class="card-img-top" style="height:100px;object-fit:cover;">';
|
||||
} else if (f.is_video) {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-film fs-1 text-muted"></i></div>';
|
||||
} else if (f.is_audio) {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-music-note-beamed fs-1 text-muted"></i></div>';
|
||||
} else {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><span class="badge bg-secondary fs-5">' + f.ext.toUpperCase() + '</span></div>';
|
||||
}
|
||||
|
||||
card.innerHTML = preview +
|
||||
'<div class="card-body p-2"><small class="text-truncate d-block">' + f.name + '</small></div>';
|
||||
|
||||
card.addEventListener('click', function () {
|
||||
var m = getCurrentMode();
|
||||
if (f.is_image && m !== 'markdown') {
|
||||
showSizeForm(f);
|
||||
} else {
|
||||
insertMedia(f, m);
|
||||
}
|
||||
});
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('media-grid').innerHTML = '<div class="col-12 text-center text-danger py-4">Fout bij laden van media.</div>';
|
||||
});
|
||||
});
|
||||
|
||||
function showSizeForm(f) {
|
||||
pendingFile = f;
|
||||
document.getElementById('media-grid').classList.add('d-none');
|
||||
document.getElementById('media-size-form').classList.remove('d-none');
|
||||
document.getElementById('size-preview').src = f.url;
|
||||
document.getElementById('size-filename').textContent = f.name;
|
||||
document.getElementById('size-width').value = '';
|
||||
document.getElementById('size-height').value = '';
|
||||
}
|
||||
|
||||
document.getElementById('size-insert-btn').addEventListener('click', function () {
|
||||
if (!pendingFile) return;
|
||||
var w = document.getElementById('size-width').value;
|
||||
var h = document.getElementById('size-height').value;
|
||||
insertMedia(pendingFile, getCurrentMode(), w, h);
|
||||
});
|
||||
|
||||
document.getElementById('size-cancel-btn').addEventListener('click', function () {
|
||||
document.getElementById('media-size-form').classList.add('d-none');
|
||||
document.getElementById('media-grid').classList.remove('d-none');
|
||||
pendingFile = null;
|
||||
});
|
||||
|
||||
function insertMedia(f, mode, w, h) {
|
||||
var editorEl = document.querySelector('.CodeMirror');
|
||||
if (!editorEl || typeof CodeMirror === 'undefined') return;
|
||||
var cm = editorEl.CodeMirror;
|
||||
if (!cm) return;
|
||||
|
||||
var sizeAttr = '';
|
||||
if (w || h) {
|
||||
if (w) sizeAttr += ' width="' + parseInt(w) + '"';
|
||||
if (h) sizeAttr += ' height="' + parseInt(h) + '"';
|
||||
}
|
||||
|
||||
var tag;
|
||||
if (mode === 'markdown') {
|
||||
if (f.is_image) {
|
||||
tag = '';
|
||||
} else {
|
||||
tag = '[' + f.name + '](' + f.url + ')';
|
||||
}
|
||||
} else {
|
||||
if (f.is_image) {
|
||||
tag = '<img src="' + f.url + '" alt="' + f.name + '"' + sizeAttr + '>';
|
||||
} else if (f.is_video) {
|
||||
tag = '<video controls src="' + f.url + '" style="max-width:100%;"></video>';
|
||||
} else if (f.is_audio) {
|
||||
tag = '<audio controls src="' + f.url + '"></audio>';
|
||||
} else {
|
||||
tag = '<a href="' + f.url + '">' + f.name + '</a>';
|
||||
}
|
||||
}
|
||||
cm.replaceSelection(tag);
|
||||
cm.focus();
|
||||
|
||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
|
||||
// Handle upload
|
||||
document.getElementById('media-upload-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
var formData = new FormData(form);
|
||||
formData.append('csrf_token', '<?= $csrf ?>');
|
||||
|
||||
fetch('/admin/media', { method: 'POST', body: formData })
|
||||
.then(function () {
|
||||
form.reset();
|
||||
document.getElementById('media-upload-btn').disabled = true;
|
||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
||||
if (modal) modal.hide();
|
||||
setTimeout(function () { modal.show(); }, 100);
|
||||
})
|
||||
.catch(function () {
|
||||
alert('Upload mislukt.');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('media-file-input').addEventListener('change', function () {
|
||||
document.getElementById('media-upload-btn').disabled = this.files.length === 0;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
@@ -1,34 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> <?= is_file($fullPath) ? 'Bestand' : 'Map' ?> verplaatsen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Te verplaatsen item</label>
|
||||
<p class="form-control-plaintext fw-bold">
|
||||
<i class="bi <?= is_file($fullPath) ? 'bi-file' : 'bi-folder' ?>"></i>
|
||||
<?= htmlspecialchars(basename($fullPath)) ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="target_dir" class="form-label">Doelmap</label>
|
||||
<select class="form-select" id="target_dir" name="target_dir" required>
|
||||
<option value="">-- Selecteer doelmap --</option>
|
||||
<option value="">/ (hoofdmap)</option>
|
||||
<?php foreach ($dirs as $d): ?>
|
||||
<option value="<?= htmlspecialchars($d) ?>"><?= htmlspecialchars($d) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Selecteer de map waar het item naartoe verplaatst moet worden.</div>
|
||||
</div>
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<a href="/admin/content?dir=<?= urlencode(dirname($item) === '.' ? '' : dirname($item)) ?>" class="btn btn-secondary">Annuleren</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Verplaatsen</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,315 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-new?dir=<?= urlencode($dir ?? '') ?>" id="editor-form" data-new-page>
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" placeholder="bijv. mijn-pagina" required>
|
||||
<small class="form-text text-muted">Extensie wordt automatisch toegevoegd.</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="type" class="form-label">Type</label>
|
||||
<select class="form-select" id="type" name="type" data-editor-mode>
|
||||
<option value="md" selected>Markdown (.md)</option>
|
||||
<option value="php">PHP (.php)</option>
|
||||
<option value="html">HTML (.html)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($dir)): ?>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Map: <?= htmlspecialchars($dir) ?></small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout</label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
<?php if (empty($themeLayouts)): ?>
|
||||
<option value="sidebar-content">Sidebar + Inhoud (standaard)</option>
|
||||
<option value="content">Alleen inhoud (full-width)</option>
|
||||
<option value="sidebar">Alleen sidebar (full-width)</option>
|
||||
<option value="content-sidebar">Inhoud links + sidebar rechts</option>
|
||||
<option value="content-sidebar-reverse">Inhoud rechts + sidebar links</option>
|
||||
<?php else: ?>
|
||||
<?php foreach ($themeLayouts as $key => $file): ?>
|
||||
<option value="<?= htmlspecialchars($key) ?>"><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $key))) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php if (!empty($availablePlugins)): ?>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label d-block">Zichtbare plugins</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<?php foreach ($availablePlugins as $plugin): ?>
|
||||
<input type="checkbox" class="btn-check" id="plugin-<?= $plugin ?>" name="plugins[]" value="<?= htmlspecialchars($plugin) ?>" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-primary btn-sm" for="plugin-<?= $plugin ?>"><?= htmlspecialchars($plugin) ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="md"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" id="content-create-btn" disabled>
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/content?dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Modal -->
|
||||
<div class="modal fade" id="mediaModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-images"></i> Media</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="collapse mb-3" id="mediaUploadForm">
|
||||
<div class="card card-body">
|
||||
<form id="media-upload-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="mb-2">
|
||||
<input type="file" class="form-control" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,audio/mpeg,audio/wav" id="media-file-input">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-sm" id="media-upload-btn" disabled>
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#mediaUploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
<small class="text-muted" id="media-count"></small>
|
||||
</div>
|
||||
<div id="media-grid" class="row g-2">
|
||||
<div class="col-12 text-center text-muted py-4">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div> Laden...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size form (shown when clicking an image) -->
|
||||
<div id="media-size-form" class="d-none">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<img id="size-preview" src="" alt="" style="width:80px;height:60px;object-fit:cover;border-radius:4px;">
|
||||
<div>
|
||||
<strong id="size-filename" class="d-block"></strong>
|
||||
<small class="text-muted">Geef de gewenste afmetingen (optioneel)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Breedte (px)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="size-width" placeholder="auto" min="1">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Hoogte (px)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="size-height" placeholder="auto" min="1">
|
||||
</div>
|
||||
<div class="col-4 d-flex align-items-end gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="size-insert-btn">
|
||||
<i class="bi bi-check-lg"></i> Invoegen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="size-cancel-btn">
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var backBtn = document.getElementById('back-btn');
|
||||
var filenameInput = document.getElementById('filename');
|
||||
var createBtn = document.getElementById('content-create-btn');
|
||||
|
||||
if (backBtn) {
|
||||
var changed = false;
|
||||
function markChanged() {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
||||
backBtn.classList.remove('btn-outline-secondary');
|
||||
backBtn.classList.add('btn-outline-danger');
|
||||
}
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', markChanged);
|
||||
}
|
||||
window.__onContentChange = markChanged;
|
||||
}
|
||||
|
||||
if (filenameInput && createBtn) {
|
||||
filenameInput.addEventListener('input', function () {
|
||||
createBtn.disabled = this.value.trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
var mediaModal = document.getElementById('mediaModal');
|
||||
if (!mediaModal) return;
|
||||
|
||||
function getCurrentMode() {
|
||||
var ta = document.getElementById('editor-textarea');
|
||||
var ext = ta ? ta.dataset.ext || 'md' : 'md';
|
||||
return ext === 'md' ? 'markdown' : 'html';
|
||||
}
|
||||
|
||||
var pendingFile = null;
|
||||
|
||||
mediaModal.addEventListener('show.bs.modal', function () {
|
||||
document.getElementById('media-size-form').classList.add('d-none');
|
||||
document.getElementById('media-grid').classList.remove('d-none');
|
||||
pendingFile = null;
|
||||
fetch('/admin/media-list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (files) {
|
||||
var grid = document.getElementById('media-grid');
|
||||
document.getElementById('media-count').textContent = files.length + ' bestand(en)';
|
||||
if (files.length === 0) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">Geen media bestanden gevonden.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
files.forEach(function (f) {
|
||||
var col = document.createElement('div');
|
||||
col.className = 'col-6 col-md-4 col-lg-3';
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card card-media-item';
|
||||
card.style.cursor = 'pointer';
|
||||
card.title = 'Klik om in te voegen';
|
||||
|
||||
var preview;
|
||||
if (f.is_image) {
|
||||
preview = '<img src="' + f.url + '" alt="' + f.name + '" class="card-img-top" style="height:100px;object-fit:cover;">';
|
||||
} else if (f.is_video) {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-film fs-1 text-muted"></i></div>';
|
||||
} else if (f.is_audio) {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><i class="bi bi-music-note-beamed fs-1 text-muted"></i></div>';
|
||||
} else {
|
||||
preview = '<div class="d-flex align-items-center justify-content-center" style="height:100px;background:#f8f9fa;"><span class="badge bg-secondary fs-5">' + f.ext.toUpperCase() + '</span></div>';
|
||||
}
|
||||
|
||||
card.innerHTML = preview +
|
||||
'<div class="card-body p-2"><small class="text-truncate d-block">' + f.name + '</small></div>';
|
||||
|
||||
card.addEventListener('click', function () {
|
||||
var mode = getCurrentMode();
|
||||
if (f.is_image && mode !== 'markdown') {
|
||||
showSizeForm(f);
|
||||
} else {
|
||||
insertMedia(f, mode);
|
||||
}
|
||||
});
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('media-grid').innerHTML = '<div class="col-12 text-center text-danger py-4">Fout bij laden van media.</div>';
|
||||
});
|
||||
});
|
||||
|
||||
function showSizeForm(f) {
|
||||
pendingFile = f;
|
||||
document.getElementById('media-grid').classList.add('d-none');
|
||||
document.getElementById('media-size-form').classList.remove('d-none');
|
||||
document.getElementById('size-preview').src = f.url;
|
||||
document.getElementById('size-filename').textContent = f.name;
|
||||
document.getElementById('size-width').value = '';
|
||||
document.getElementById('size-height').value = '';
|
||||
}
|
||||
|
||||
document.getElementById('size-insert-btn').addEventListener('click', function () {
|
||||
if (!pendingFile) return;
|
||||
var w = document.getElementById('size-width').value;
|
||||
var h = document.getElementById('size-height').value;
|
||||
insertMedia(pendingFile, getCurrentMode(), w, h);
|
||||
});
|
||||
|
||||
document.getElementById('size-cancel-btn').addEventListener('click', function () {
|
||||
document.getElementById('media-size-form').classList.add('d-none');
|
||||
document.getElementById('media-grid').classList.remove('d-none');
|
||||
pendingFile = null;
|
||||
});
|
||||
|
||||
function insertMedia(f, mode, w, h) {
|
||||
var editorEl = document.querySelector('.CodeMirror');
|
||||
if (!editorEl || typeof CodeMirror === 'undefined') return;
|
||||
var cm = editorEl.CodeMirror;
|
||||
if (!cm) return;
|
||||
|
||||
var sizeAttr = '';
|
||||
if (w || h) {
|
||||
if (w) sizeAttr += ' width="' + parseInt(w) + '"';
|
||||
if (h) sizeAttr += ' height="' + parseInt(h) + '"';
|
||||
}
|
||||
|
||||
var tag;
|
||||
if (mode === 'markdown') {
|
||||
if (f.is_image) {
|
||||
tag = '';
|
||||
} else {
|
||||
tag = '[' + f.name + '](' + f.url + ')';
|
||||
}
|
||||
} else {
|
||||
if (f.is_image) {
|
||||
tag = '<img src="' + f.url + '" alt="' + f.name + '"' + sizeAttr + '>';
|
||||
} else if (f.is_video) {
|
||||
tag = '<video controls src="' + f.url + '" style="max-width:100%;"></video>';
|
||||
} else if (f.is_audio) {
|
||||
tag = '<audio controls src="' + f.url + '"></audio>';
|
||||
} else {
|
||||
tag = '<a href="' + f.url + '">' + f.name + '</a>';
|
||||
}
|
||||
}
|
||||
cm.replaceSelection(tag);
|
||||
cm.focus();
|
||||
|
||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
|
||||
document.getElementById('media-upload-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
var formData = new FormData(form);
|
||||
formData.append('csrf_token', '<?= $csrf ?>');
|
||||
|
||||
fetch('/admin/media', { method: 'POST', body: formData })
|
||||
.then(function () {
|
||||
form.reset();
|
||||
document.getElementById('media-upload-btn').disabled = true;
|
||||
var modal = bootstrap.Modal.getInstance(mediaModal);
|
||||
if (modal) modal.hide();
|
||||
setTimeout(function () { modal.show(); }, 100);
|
||||
})
|
||||
.catch(function () {
|
||||
alert('Upload mislukt.');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('media-file-input').addEventListener('change', function () {
|
||||
document.getElementById('media-upload-btn').disabled = this.files.length === 0;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-book"></i> Handleiding</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/guide?lang=nl" class="btn btn-sm <?= $lang === 'nl' ? 'btn-primary' : 'btn-outline-primary' ?>">Nederlands</a>
|
||||
<a href="/admin/guide?lang=en" class="btn btn-sm <?= $lang === 'en' ? 'btn-primary' : 'btn-outline-primary' ?>">English</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body guide-content">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guide-content h2 { margin-top: 1.5rem; }
|
||||
.guide-content h3 { margin-top: 1.25rem; }
|
||||
.guide-content pre { background: #f8f9fa; padding: 1rem; border-radius: 6px; overflow-x: auto; border: 1px solid #dee2e6; margin-bottom: 1rem; }
|
||||
.guide-content pre code { background: none; padding: 0; color: #333; font-size: 0.85rem; line-height: 1.5; }
|
||||
.guide-content code { background: #e8e8e8; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; color: #d63384; }
|
||||
.guide-content table { width: 100%; margin-bottom: 1rem; }
|
||||
.guide-content table th, .guide-content table td { padding: 0.5rem; border: 1px solid #dee2e6; }
|
||||
.guide-content blockquote { border-left: 3px solid #ccc; padding-left: 1rem; color: #666; margin-left: 0; }
|
||||
.guide-content .heading-permalink { display: none; }
|
||||
.guide-content ul:first-of-type { list-style: none; padding-left: 0; }
|
||||
.guide-content ul:first-of-type li { padding: 0.15rem 0; }
|
||||
.guide-content ul:first-of-type li a { text-decoration: none; color: #0d6efd; }
|
||||
.guide-content ul:first-of-type li a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -1,79 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/admin/logs" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="filter_event" class="form-label">Gebeurtenis</label>
|
||||
<select class="form-select" id="filter_event" name="event">
|
||||
<option value="">Alle</option>
|
||||
<?php foreach ($eventTypes as $ev): ?>
|
||||
<option value="<?= $ev ?>" <?= $filterEvent === $ev ? 'selected' : '' ?>><?= ucfirst($ev) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="filter_level" class="form-label">Niveau</label>
|
||||
<select class="form-select" id="filter_level" name="level">
|
||||
<option value="">Alle</option>
|
||||
<?php foreach ($levelTypes as $lv): ?>
|
||||
<option value="<?= $lv ?>" <?= $filterLevel === $lv ? 'selected' : '' ?>><?= ucfirst($lv) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="filter_search" class="form-label">Zoeken</label>
|
||||
<input type="text" class="form-control" id="filter_search" name="search" value="<?= htmlspecialchars($filterSearch) ?>" placeholder="Zoek in bericht...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="filter_limit" class="form-label">Aantal</label>
|
||||
<select class="form-select" id="filter_limit" name="limit">
|
||||
<?php foreach ([50, 100, 200, 500, 1000] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= $limit === $n ? 'selected' : '' ?>><?= $n ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-funnel"></i> Filteren</button>
|
||||
<div>
|
||||
<a href="/admin/logs?download=1<?= $filterEvent ? '&event=' . urlencode($filterEvent) : '' ?><?= $filterLevel ? '&level=' . urlencode($filterLevel) : '' ?><?= $filterSearch ? '&search=' . urlencode($filterSearch) : '' ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
|
||||
<a href="/admin/logs?clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Log wissen?')"><i class="bi bi-trash"></i> Wissen</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<small class="text-muted"><?= count($logEntries) ?> regels</small>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0" style="max-height: 600px; overflow-y: auto;">
|
||||
<?php if (empty($logEntries)): ?>
|
||||
<p class="text-muted p-3 mb-0">Geen logregels gevonden.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Tijd</th>
|
||||
<th>Gebeurtenis</th>
|
||||
<th>Niveau</th>
|
||||
<th>IP</th>
|
||||
<th>Bericht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logEntries as $log): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
|
||||
<td><span class="badge bg-secondary"><?= htmlspecialchars($log['event']) ?></span></td>
|
||||
<td><span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?>"><?= htmlspecialchars($log['level']) ?></span></td>
|
||||
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
|
||||
<td><?= htmlspecialchars($log['message']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,69 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-images"></i> Media</h2>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse mb-4" id="uploadForm">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/media" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
||||
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($files)): ?>
|
||||
<div class="alert alert-info">Geen bestanden gevonden in de assets map.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voorbeeld</th>
|
||||
<th>Bestand</th>
|
||||
<th>Grootte</th>
|
||||
<th>Datum</th>
|
||||
<th>URL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($files as $file): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php if ($file['is_image']): ?>
|
||||
<img src="<?= htmlspecialchars($file['url']) ?>" style="width: 60px; height: 40px; object-fit: cover;" class="img-thumbnail">
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary fs-6"><?= strtoupper($file['ext']) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><strong><?= htmlspecialchars($file['name']) ?></strong></td>
|
||||
<td class="text-muted small"><?= htmlspecialchars($file['size'] > 1048576 ? round($file['size'] / 1048576, 1) . ' MB' : round($file['size'] / 1024, 1) . ' KB') ?></td>
|
||||
<td class="text-muted small"><?= htmlspecialchars($file['modified']) ?></td>
|
||||
<td><code class="small"><?= htmlspecialchars($file['url']) ?></code></td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/media" class="d-inline" onsubmit="return confirm('Weet je zeker dat je '<?= htmlspecialchars($file['name']) ?>' wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="delete" value="<?= htmlspecialchars($file['name']) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -1,74 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugin Configuratie: <?= htmlspecialchars($pluginName) ?></h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (empty($pluginConfig)): ?>
|
||||
<div class="alert alert-info">Deze plugin heeft geen configureerbare instellingen.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($pluginConfig as $key => $value): ?>
|
||||
<?= renderConfigField($key, $value) ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($pluginConfig)): ?>
|
||||
<div class="card-footer text-end">
|
||||
<a href="/admin/plugins" class="btn btn-secondary">Annuleren</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Opslaan</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Terug naar plugins
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
function renderConfigField(string $key, $value, string $prefix = ''): string
|
||||
{
|
||||
$name = $prefix ? $prefix . '[' . $key . ']' : 'config[' . $key . ']';
|
||||
$id = 'cfg_' . str_replace(['.', '['], '_', rtrim($name, ']'));
|
||||
$label = ucwords(str_replace('_', ' ', $key));
|
||||
$html = '';
|
||||
|
||||
if (is_bool($value)) {
|
||||
$checked = $value ? 'checked' : '';
|
||||
$html .= '<div class="mb-3 form-check form-switch">';
|
||||
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" value="0">';
|
||||
$html .= '<input class="form-check-input" type="checkbox" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="1" ' . $checked . '>';
|
||||
$html .= '<label class="form-check-label" for="' . htmlspecialchars($id) . '">' . htmlspecialchars($label) . '</label>';
|
||||
$html .= '</div>';
|
||||
} elseif (is_numeric($value)) {
|
||||
$step = is_float($value) ? 'step="0.01"' : 'step="1"';
|
||||
$html .= '<div class="mb-3">';
|
||||
$html .= '<label for="' . htmlspecialchars($id) . '" class="form-label">' . htmlspecialchars($label) . '</label>';
|
||||
$html .= '<input type="number" class="form-control" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value) . '" ' . $step . '>';
|
||||
$html .= '</div>';
|
||||
} elseif (is_array($value)) {
|
||||
$html .= '<div class="mb-3">';
|
||||
$html .= '<label class="form-label fw-bold">' . htmlspecialchars($label) . '</label>';
|
||||
$html .= '<div class="card bg-light">';
|
||||
$html .= '<div class="card-body">';
|
||||
foreach ($value as $subKey => $subValue) {
|
||||
$html .= renderConfigField($subKey, $subValue, $name);
|
||||
}
|
||||
$html .= '</div></div></div>';
|
||||
} else {
|
||||
$html .= '<div class="mb-3">';
|
||||
$html .= '<label for="' . htmlspecialchars($id) . '" class="form-label">' . htmlspecialchars($label) . '</label>';
|
||||
|
||||
if (strlen($value) > 80 || str_contains($value, "\n")) {
|
||||
$html .= '<textarea class="form-control font-monospace" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" rows="4">' . htmlspecialchars($value) . '</textarea>';
|
||||
} else {
|
||||
$html .= '<input type="text" class="form-control" id="' . htmlspecialchars($id) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value) . '">';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-pencil"></i> Plugin bewerken: <?= htmlspecialchars($pluginName) ?></h2>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Terug
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/plugins-edit?plugin=<?= urlencode($pluginName) ?>" id="editor-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-secondary">PHP</span>
|
||||
<small class="text-muted"><?= htmlspecialchars($pluginName) ?>/<?= htmlspecialchars($pluginName) ?>.php</small>
|
||||
</div>
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="php"><?= htmlspecialchars($fileContent) ?></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Nieuwe plugin aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="plugin_name" class="form-label">Plugin naam</label>
|
||||
<input type="text" class="form-control font-monospace" id="plugin_name" name="plugin_name"
|
||||
value="<?= htmlspecialchars($_POST['plugin_name'] ?? '') ?>" required
|
||||
placeholder="bijv. MijnPlugin">
|
||||
<div class="form-text">Alleen letters, cijfers en underscores. Begin met een hoofdletter. Wordt gebruikt als <strong>mapnaam</strong>, <strong>bestandsnaam</strong> en <strong>class naam</strong>.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="plugin_desc" class="form-label">Omschrijving <small class="text-muted">(optioneel)</small></label>
|
||||
<textarea class="form-control" id="plugin_desc" name="plugin_desc" rows="3"
|
||||
placeholder="Korte omschrijving van wat de plugin doet..."><?= htmlspecialchars($_POST['plugin_desc'] ?? '') ?></textarea>
|
||||
<div class="form-text">Wordt opgeslagen als README.md bij de plugin.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-0">
|
||||
<strong><i class="bi bi-lightbulb"></i> Wat wordt er aangemaakt?</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li><code>plugins/PluginNaam/PluginNaam.php</code> — Hoofdbestand met boilerplate</li>
|
||||
<li><code>plugins/PluginNaam/config.json</code> — Configuratiebestand</li>
|
||||
<li><code>plugins/PluginNaam/README.md</code> — Documentatie (alleen bij omschrijving)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<a href="/admin/plugins" class="btn btn-secondary">Annuleren</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,157 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-plug"></i> Plugins</h2>
|
||||
<a href="/admin/plugins-new" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuwe plugin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-info-circle"></i> Plugin Ontwikkelaarshandleiding</h5>
|
||||
<p class="text-muted mb-2">Een plugin moet aan de volgende eisen voldoen om correct te werken:</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-folder2-open text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Mapstructuur</strong><br>
|
||||
<code class="small">plugins/Naam/Naam.php</code>
|
||||
<small class="text-muted d-block">Mapnaam en bestandsnaam moeten identiek zijn.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-code-square text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Class naam</strong><br>
|
||||
<code class="small">class PluginNaam</code>
|
||||
<small class="text-muted d-block">De class moet exact dezelfde naam hebben als de map.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-puzzle text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Optionele hooks</strong><br>
|
||||
<code class="small">setAPI(CMSAPI)</code> · <code class="small">getSidebarContent()</code>
|
||||
<small class="text-muted d-block">Voor CMS-toegang en sidebar-weergave.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-gear-wide text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Configuratie (optioneel)</strong><br>
|
||||
<code class="small">config.json</code>
|
||||
<small class="text-muted d-block">Wordt getoond met een configuratieformulier in de admin.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-shield-check text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Beveiliging</strong><br>
|
||||
<code class="small">htmlspecialchars()</code>
|
||||
<small class="text-muted d-block">Altijd output escapen. Volg PSR-12.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-book text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Documentatie (optioneel)</strong><br>
|
||||
<code class="small">README.md</code>
|
||||
<small class="text-muted d-block">Aangeraden voor uitleg over de plugin.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($plugins)): ?>
|
||||
<div class="alert alert-info">Geen plugins gevonden in de plugins map.</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($plugins as $plugin): ?>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong>
|
||||
<div>
|
||||
<?php if ($plugin['enabled']): ?>
|
||||
<span class="badge bg-success me-1">Actief</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary me-1">Uitgeschakeld</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($plugin['viewable']): ?>
|
||||
<span class="badge bg-info">Zichtbaar</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary">Systeem</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr>
|
||||
<td class="text-muted">Hoofdbestand</td>
|
||||
<td>
|
||||
<?= $plugin['has_main'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-x-circle text-danger"></i> Ontbreekt' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Configuratie</td>
|
||||
<td>
|
||||
<?= $plugin['has_config'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-dash-circle text-muted"></i> Geen' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">README</td>
|
||||
<td>
|
||||
<?= $plugin['has_readme'] ? '<i class="bi bi-check-circle text-success"></i> Aanwezig' : '<i class="bi bi-dash-circle text-muted"></i> Geen' ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<?php if ($plugin['has_main']): ?>
|
||||
<a href="/admin/plugins-edit?plugin=<?= urlencode($plugin['name']) ?>" class="btn btn-outline-secondary" title="Bewerken">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/admin/plugins-toggle?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<button type="submit" class="btn btn-sm <?= $plugin['enabled'] ? 'btn-outline-warning' : 'btn-outline-success' ?>" title="<?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>">
|
||||
<i class="bi <?= $plugin['enabled'] ? 'bi-pause-circle' : 'bi-play-circle' ?>"></i> <?= $plugin['enabled'] ? 'Uitschakelen' : 'Activeren' ?>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/plugins-toggle-visibility?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<button type="submit" class="btn btn-sm <?= $plugin['viewable'] ? 'btn-outline-secondary' : 'btn-outline-info' ?>" title="<?= $plugin['viewable'] ? 'Verbergen' : 'Tonen' ?>">
|
||||
<i class="bi <?= $plugin['viewable'] ? 'bi-eye-slash' : 'bi-eye' ?>"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php if ($plugin['has_config']): ?>
|
||||
<a href="/admin/plugins-config?plugin=<?= urlencode($plugin['name']) ?>" class="btn btn-outline-primary" title="Configureren">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="POST" action="/admin/plugins-delete?plugin=<?= urlencode($plugin['name']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je de plugin '<?= htmlspecialchars($plugin['name']) ?>' wilt verwijderen? Alle bestanden worden permanent verwijderd.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -1,114 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-shield-check"></i> Beveiliging & Bot Bescherming</h2>
|
||||
|
||||
<form method="POST" action="/admin/security">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<i class="bi bi-robot"></i> Bot, AI & Scraper Blokkering
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_ai_bots" name="block_ai_bots" value="1" <?= !empty($sec['block_ai_bots']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="block_ai_bots">
|
||||
<i class="bi bi-cpu text-danger"></i> AI Crawlers & Scrapers blokkeren (403 Forbidden)
|
||||
</label>
|
||||
<div class="form-text">Blokkeert bekende AI-bots zoals <code>GPTBot</code>, <code>ChatGPT-User</code>, <code>ClaudeBot</code>, <code>PerplexityBot</code>, <code>CCBot</code>, <code>Google-Extended</code>, <code>Bytespider</code>, <code>Applebot-Extended</code>, etc.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_scrapers" name="block_scrapers" value="1" <?= !empty($sec['block_scrapers']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="block_scrapers">
|
||||
<i class="bi bi-bug text-warning"></i> Geautomatiseerde Scrapers & Tools blokkeren (403 Forbidden)
|
||||
</label>
|
||||
<div class="form-text">Blokkeert automatische scraping tools zoals <code>HTTrack</code>, <code>Scrapy</code>, <code>PhantomJS</code>, <code>HeadlessChrome</code>, <code>cURL</code>, <code>Wget</code>, <code>Python-requests</code>, <code>libwww-perl</code>, etc.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_empty_user_agent" name="block_empty_user_agent" value="1" <?= !empty($sec['block_empty_user_agent']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="block_empty_user_agent">
|
||||
<i class="bi bi-slash-circle text-secondary"></i> Verzoeken met lege User-Agent header blokkeren
|
||||
</label>
|
||||
<div class="form-text">Veel eenvoudige bots en aanvalscripts sturen geen User-Agent header mee.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_search_engines" name="block_search_engines" value="1" <?= !empty($sec['block_search_engines']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold text-danger" for="block_search_engines">
|
||||
<i class="bi bi-search"></i> Legitieme zoekmachines blokkeren (Google, Bing, DuckDuckGo, etc.)
|
||||
</label>
|
||||
<div class="form-text text-danger">Let op: Schakel dit alleen in als je wilt dat de hele site niet in zoekmachines (zoals Google en Bing) verschijnt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-speedometer2"></i> Snelheidsbeperking (Rate Limiting per IP)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="rate_limit_enabled" name="rate_limit_enabled" value="1" <?= !empty($sec['rate_limit_enabled']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="rate_limit_enabled">Rate Limiter inschakelen</label>
|
||||
<div class="form-text">Voorkomt dat scrapers of bots de site overbelasten door tientallen verzoeken per seconde uit te voeren. Overschrijding geeft HTTP 429.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="rate_limit_max" class="form-label">Maximaal aantal verzoeken per IP</label>
|
||||
<input type="number" class="form-control" id="rate_limit_max" name="rate_limit_max" min="10" max="1000" value="<?= (int)($sec['rate_limit_max'] ?? 60) ?>">
|
||||
<small class="form-text text-muted">Aanbevolen: 60 verzoeken.</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="rate_limit_window" class="form-label">Tijdvenster (in seconden)</label>
|
||||
<input type="number" class="form-control" id="rate_limit_window" name="rate_limit_window" min="10" max="3600" value="<?= (int)($sec['rate_limit_window'] ?? 60) ?>">
|
||||
<small class="form-text text-muted">Aanbevolen: 60 seconden (1 minuut).</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list-check"></i> Eigen Filters & IP-Lijsten
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="custom_blocked_agents" class="form-label fw-bold">Aangepaste User-Agent Blocklist</label>
|
||||
<textarea class="form-control font-monospace" id="custom_blocked_agents" name="custom_blocked_agents" rows="3" placeholder="Typ één User-Agent patroon per regel (bijv. MyCustomBot)"><?= htmlspecialchars(implode("\n", $sec['custom_blocked_agents'] ?? [])) ?></textarea>
|
||||
<div class="form-text">Verzoeken waarvan de User-Agent dit patroon bevat krijgen een 403 Forbidden.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="allowed_ips" class="form-label fw-bold text-success"><i class="bi bi-shield-check"></i> IP Whitelist (Altijd Toegang)</label>
|
||||
<textarea class="form-control font-monospace" id="allowed_ips" name="allowed_ips" rows="3" placeholder="Één IP per regel (bijv. 82.169.10.20)"><?= htmlspecialchars(implode("\n", $sec['allowed_ips'] ?? [])) ?></textarea>
|
||||
<div class="form-text text-success">IP's op de whitelist worden nooit geblokkeerd door bot-filters of rate limiting.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="blocked_ips" class="form-label fw-bold text-danger"><i class="bi bi-shield-x"></i> IP Blocklist (Altijd Geblokkeerd)</label>
|
||||
<textarea class="form-control font-monospace" id="blocked_ips" name="blocked_ips" rows="3" placeholder="Één IP per regel (bijv. 198.51.100.4)"><?= htmlspecialchars(implode("\n", $sec['blocked_ips'] ?? [])) ?></textarea>
|
||||
<div class="form-text text-danger">IP's op de blocklist krijgen altijd direct een HTTP 403 Forbidden.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg mb-4">
|
||||
<i class="bi bi-check-lg"></i> Beveiligingsinstellingen opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-file-earmark-text"></i> Dynamische <code>robots.txt</code> Preview
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="bg-dark text-light p-3 rounded-bottom">
|
||||
<pre class="m-0 text-light font-monospace small"><?= htmlspecialchars($robotsPreview ?? '') ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted small">
|
||||
Deze <code>robots.txt</code> wordt automatisch geserveerd op <code>/robots.txt</code> en past zich aan je instellingen aan.
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,378 +0,0 @@
|
||||
<?php
|
||||
$totals = $stats['totals'] ?? [];
|
||||
$countries = $stats['countries'] ?? [];
|
||||
$pages = $stats['pages'] ?? [];
|
||||
$referrers = $stats['referrers'] ?? [];
|
||||
$daily = $stats['daily_chart'] ?? [];
|
||||
|
||||
// Exclude the "UNKNOWN" bucket from the map scale
|
||||
$mapCountries = $countries;
|
||||
unset($mapCountries['UNKNOWN']);
|
||||
$maxCountry = !empty($mapCountries) ? max($mapCountries) : 0;
|
||||
$maxPage = !empty($pages) ? max($pages) : 0;
|
||||
$maxDaily = 0;
|
||||
foreach ($daily as $d) {
|
||||
if (($d['views'] ?? 0) > $maxDaily) $maxDaily = $d['views'];
|
||||
}
|
||||
|
||||
$periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatste 90 dagen', 0 => 'Alles'];
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<div class="btn-group">
|
||||
<?php foreach ($periodLabels as $p => $label): ?>
|
||||
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="/admin/statistics?period=<?= $period ?>&export=csv" class="btn btn-sm btn-outline-success" title="Exporteer als CSV">
|
||||
<i class="bi bi-filetype-csv"></i> CSV
|
||||
</a>
|
||||
<a href="/admin/statistics?period=<?= $period ?>&export=json" class="btn btn-sm btn-outline-success" title="Exporteer als JSON">
|
||||
<i class="bi bi-filetype-json"></i> JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card stat-card shadow-sm h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Paginaweergaven</h6>
|
||||
<h3 class="mb-0"><?= number_format((int)($totals['views'] ?? 0), 0, ',', '.') ?></h3>
|
||||
</div>
|
||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card stat-card shadow-sm h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
|
||||
<h3 class="mb-0"><?= number_format((int)($totals['uniques'] ?? 0), 0, ',', '.') ?></h3>
|
||||
</div>
|
||||
<i class="bi bi-people stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card stat-card shadow-sm h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Mens / Bot</h6>
|
||||
<h3 class="mb-0">
|
||||
<span class="text-success"><?= number_format((int)($totals['human'] ?? 0), 0, ',', '.') ?></span>
|
||||
<small class="text-muted">/</small>
|
||||
<span class="text-secondary"><?= number_format((int)($totals['bot'] ?? 0), 0, ',', '.') ?></span>
|
||||
</h3>
|
||||
</div>
|
||||
<i class="bi bi-person-check stat-icon text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card stat-card shadow-sm h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Geblokkeerd</h6>
|
||||
<h3 class="mb-0 text-danger"><?= number_format((int)($totals['blocked'] ?? 0), 0, ',', '.') ?></h3>
|
||||
</div>
|
||||
<i class="bi bi-shield-x stat-icon text-danger"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- World map -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-globe-europe-africa"></i> Bezoekers per land</span>
|
||||
<?php if ($maxCountry > 0): ?>
|
||||
<small class="text-muted d-flex align-items-center gap-1">
|
||||
Minder
|
||||
<span style="display:inline-block;width:18px;height:12px;background:#cfe2ff;border:1px solid #dee2e6;"></span>
|
||||
<span style="display:inline-block;width:18px;height:12px;background:#6ea8fe;"></span>
|
||||
<span style="display:inline-block;width:18px;height:12px;background:#0d6efd;"></span>
|
||||
<span style="display:inline-block;width:18px;height:12px;background:#052c65;"></span>
|
||||
Meer
|
||||
</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($worldMapSvg === ''): ?>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle"></i> Wereldkaart niet gevonden. Genereer deze met:
|
||||
<code>php cli/generate-world-map.php</code>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<style>
|
||||
<?php foreach ($mapCountries as $cc => $count):
|
||||
if (!preg_match('/^[A-Z]{2}$/', $cc) || $maxCountry <= 0) continue;
|
||||
$ratio = $count / $maxCountry;
|
||||
if ($ratio > 0.66) $fill = '#052c65';
|
||||
elseif ($ratio > 0.33) $fill = '#0d6efd';
|
||||
elseif ($ratio > 0.1) $fill = '#6ea8fe';
|
||||
else $fill = '#cfe2ff';
|
||||
?>
|
||||
#<?= $cc ?> { fill: <?= $fill ?>; }
|
||||
<?php endforeach; ?>
|
||||
</style>
|
||||
<div class="world-map-wrapper position-relative">
|
||||
<?= $worldMapSvg ?>
|
||||
<div id="mapTooltip" class="position-absolute bg-dark text-white px-2 py-1 rounded small" style="display:none;pointer-events:none;z-index:10;"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var counts = <?= json_encode($mapCountries) ?>;
|
||||
var wrapper = document.querySelector('.world-map-wrapper');
|
||||
var tooltip = document.getElementById('mapTooltip');
|
||||
if (!wrapper || !tooltip) return;
|
||||
wrapper.querySelectorAll('path.country').forEach(function (p) {
|
||||
p.addEventListener('mousemove', function (e) {
|
||||
var code = p.getAttribute('id');
|
||||
var name = p.getAttribute('data-name') || code;
|
||||
var n = counts[code] || 0;
|
||||
tooltip.textContent = name + ': ' + n + ' weergave' + (n === 1 ? '' : 'n');
|
||||
tooltip.style.display = 'block';
|
||||
var r = wrapper.getBoundingClientRect();
|
||||
tooltip.style.left = (e.clientX - r.left + 12) + 'px';
|
||||
tooltip.style.top = (e.clientY - r.top + 12) + 'px';
|
||||
});
|
||||
p.addEventListener('mouseleave', function () {
|
||||
tooltip.style.display = 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($geoMeta): ?>
|
||||
<div class="card-footer text-muted small">
|
||||
<?= htmlspecialchars($geoMeta['attribution'] ?? 'IP geolocation by DB-IP') ?> ·
|
||||
Database bijgewerkt op <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Countries list -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="bi bi-flag"></i> Landen</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
<?php if (empty($countries)): ?>
|
||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
||||
<?php else: ?>
|
||||
<?php $totalCountryViews = array_sum($countries); ?>
|
||||
<?php foreach (array_slice($countries, 0, 25, true) as $cc => $count): ?>
|
||||
<?php $pct = $totalCountryViews > 0 ? round(($count / $totalCountryViews) * 100, 1) : 0; ?>
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between small">
|
||||
<span>
|
||||
<?= GeoIP::getCountryFlagEmoji($cc === 'UNKNOWN' ? null : $cc) ?>
|
||||
<?= htmlspecialchars(GeoIP::getCountryName($cc === 'UNKNOWN' ? null : $cc)) ?>
|
||||
</span>
|
||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?> (<?= $pct ?>%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar" style="width: <?= $pct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top pages -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="bi bi-file-earmark-text"></i> Meest gelezen pagina's</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
<?php if (empty($pages)): ?>
|
||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach (array_slice($pages, 0, 25, true) as $pageName => $count): ?>
|
||||
<?php $pct = $maxPage > 0 ? round(($count / $maxPage) * 100, 1) : 0; ?>
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between small">
|
||||
<span class="text-truncate" style="max-width: 70%;" title="<?= htmlspecialchars($pageName) ?>">
|
||||
<?= htmlspecialchars($pageName) ?>
|
||||
</span>
|
||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-success" style="width: <?= $pct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Daily chart -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="bi bi-graph-up"></i> Bezoekers per dag</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($daily) || $maxDaily === 0): ?>
|
||||
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$chartW = 800;
|
||||
$chartH = 200;
|
||||
$count = count($daily);
|
||||
$barW = $count > 0 ? ($chartW / $count) : 10;
|
||||
?>
|
||||
<svg viewBox="0 0 <?= $chartW ?> <?= $chartH + 25 ?>" width="100%" height="auto">
|
||||
<?php foreach (array_values($daily) as $i => $d): ?>
|
||||
<?php
|
||||
$v = (int)($d['views'] ?? 0);
|
||||
$h = $maxDaily > 0 ? ($v / $maxDaily) * $chartH : 0;
|
||||
$x = $i * $barW;
|
||||
$y = $chartH - $h;
|
||||
?>
|
||||
<rect x="<?= round($x + 1, 2) ?>" y="<?= round($y, 2) ?>"
|
||||
width="<?= round(max($barW - 2, 1), 2) ?>" height="<?= round($h, 2) ?>"
|
||||
fill="#0d6efd" rx="1">
|
||||
<title><?= htmlspecialchars($d['date']) ?>: <?= $v ?> weergaven, <?= (int)($d['uniques'] ?? 0) ?> unieke bezoekers</title>
|
||||
</rect>
|
||||
<?php endforeach; ?>
|
||||
<line x1="0" y1="<?= $chartH ?>" x2="<?= $chartW ?>" y2="<?= $chartH ?>" stroke="#dee2e6" stroke-width="1"/>
|
||||
<?php $firstDay = reset($daily); $lastDay = end($daily); ?>
|
||||
<text x="0" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d"><?= htmlspecialchars($firstDay['date'] ?? '') ?></text>
|
||||
<text x="<?= $chartW ?>" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d" text-anchor="end"><?= htmlspecialchars($lastDay['date'] ?? '') ?></text>
|
||||
</svg>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrers -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="bi bi-link-45deg"></i> Verwijzende sites</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<?php if (empty($referrers)): ?>
|
||||
<p class="text-muted mb-0">Geen verwijzingen geregistreerd.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<?php foreach (array_slice($referrers, 0, 15, true) as $host => $count): ?>
|
||||
<li class="d-flex justify-content-between border-bottom py-1 small">
|
||||
<span class="text-truncate" style="max-width: 70%;"><?= htmlspecialchars($host) ?></span>
|
||||
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GeoIP & privacy settings -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header"><i class="bi bi-geo-alt"></i> GeoIP database & privacy</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<?php if ($geoMeta): ?>
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Database aanwezig</span>
|
||||
<span class="text-muted small ms-2">
|
||||
<?= number_format((int)($geoMeta['ipv4_records'] ?? 0), 0, ',', '.') ?> IPv4 ·
|
||||
<?= number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 ·
|
||||
bijgewerkt <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
||||
</span>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="mb-1"><span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Nog geen lokale database</span></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<form method="POST" action="/admin/statistics" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="update_geoip">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-cloud-download"></i> GeoIP database bijwerken
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="POST" action="/admin/statistics">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" value="1" <?= !empty($ana['enabled']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="analytics_enabled">Statistieken bijhouden</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="anonymize_ip" name="anonymize_ip" value="1" <?= !empty($ana['anonymize_ip']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold" for="anonymize_ip">IP-adressen anonimiseren</label>
|
||||
<div class="form-text">Maskeert het laatste deel van het IP (82.169.10.x). Let op: de IP-blocklist wordt hierdoor minder bruikbaar.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="geoip_provider" class="form-label fw-bold">GeoIP bron</label>
|
||||
<select name="geoip_provider" id="geoip_provider" class="form-select">
|
||||
<option value="local" <?= ($ana['geoip_provider'] ?? 'local') === 'local' ? 'selected' : '' ?>>Lokaal (DB-IP Lite)</option>
|
||||
<option value="mmdb" <?= ($ana['geoip_provider'] ?? '') === 'mmdb' ? 'selected' : '' ?>>MaxMind database (.mmdb)</option>
|
||||
<option value="api" <?= ($ana['geoip_provider'] ?? '') === 'api' ? 'selected' : '' ?>>Externe API</option>
|
||||
</select>
|
||||
<div class="form-text">Valt automatisch terug op de lokale database.</div>
|
||||
</div>
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="geoip_mmdb_path" class="form-label fw-bold">Pad naar .mmdb bestand</label>
|
||||
<input type="text" class="form-control font-monospace" id="geoip_mmdb_path" name="geoip_mmdb_path"
|
||||
value="<?= htmlspecialchars($ana['geoip_mmdb_path'] ?? '') ?>" placeholder="/var/lib/GeoIP/GeoLite2-Country.mmdb">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="geoip_api_url" class="form-label fw-bold">API URL</label>
|
||||
<input type="text" class="form-control font-monospace" id="geoip_api_url" name="geoip_api_url"
|
||||
value="<?= htmlspecialchars($ana['geoip_api_url'] ?? '') ?>" placeholder="http://ip-api.com/json/{ip}?fields=countryCode">
|
||||
<div class="form-text"><code>{ip}</code> wordt vervangen door het IP-adres van de bezoeker.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="geoip_api_key" class="form-label fw-bold">API sleutel</label>
|
||||
<input type="text" class="form-control" id="geoip_api_key" name="geoip_api_key"
|
||||
value="<?= htmlspecialchars($ana['geoip_api_key'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="retention_days" class="form-label fw-bold">Bewaartermijn (dagen)</label>
|
||||
<input type="number" class="form-control" id="retention_days" name="retention_days" min="30" max="3650"
|
||||
value="<?= (int)($ana['retention_days'] ?? 400) ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Instellingen opslaan</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">IP geolocation by DB-IP (https://db-ip.com) · CC BY 4.0</small>
|
||||
<form method="POST" action="/admin/statistics" onsubmit="return confirm('Weet je zeker dat je ALLE statistieken wilt wissen?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="reset_stats">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Statistieken wissen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,38 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-palette"></i> Nieuw thema aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="theme_name" class="form-label">Thema naam</label>
|
||||
<input type="text" class="form-control font-monospace" id="theme_name" name="theme_name"
|
||||
value="<?= htmlspecialchars($_POST['theme_name'] ?? '') ?>" required
|
||||
placeholder="bijv. MijnThema">
|
||||
<div class="form-text">Alleen letters, cijfers, streepjes en underscores. Begin met een letter. Wordt gebruikt als <strong>mapnaam</strong>.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-0">
|
||||
<strong><i class="bi bi-lightbulb"></i> Wat wordt er aangemaakt?</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li><code>themes/ThemaNaam/theme.json</code> — Configuratie (default template + template mapping)</li>
|
||||
<li><code>themes/ThemaNaam/base.twig</code> — Hoofd layout</li>
|
||||
<li><code>themes/ThemaNaam/*.twig</code> — Layout-sjablonen</li>
|
||||
<li><code>themes/ThemaNaam/partials/</code> — header, navigation, footer</li>
|
||||
<li><code>themes/ThemaNaam/css/theme.scss</code> — Styling (kleuren, hoogtes, achtergrond)</li>
|
||||
<li><code>themes/ThemaNaam/js/theme.js</code> — Thema JavaScript</li>
|
||||
<li><code>themes/ThemaNaam/theme.png</code> — Voorbeeldafbeelding</li>
|
||||
</ul>
|
||||
<p class="text-muted mb-0 mt-2">Het nieuwe thema wordt gekopieerd van het standaard thema. Pas daarna de SCSS-kleuren en sjablonen aan naar wens.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<a href="/admin/theme" class="btn btn-secondary">Annuleren</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,104 +0,0 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-palette"></i> Thema's</h2>
|
||||
<a href="/admin/theme-new" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuwe thema
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-info-circle"></i> Thema-ontwikkelaarshandleiding</h5>
|
||||
<p class="text-muted mb-2">Een thema is een volledig zelfstandige map en moet aan de volgende eisen voldoen:</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-folder2-open text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Mapstructuur</strong><br>
|
||||
<code class="small">themes/Naam/theme.json</code>
|
||||
<small class="text-muted d-block">Elke thema-map moet een <code>theme.json</code> bevatten.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-file-earmark-code text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Sjablonen (Twig)</strong><br>
|
||||
<code class="small">base.twig + *.twig</code>
|
||||
<small class="text-muted d-block">Layout-sjablonen die via <code>config.default_template</code> en <code>template</code> worden gekozen.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-palette text-primary me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Styling & voorbeeld</strong><br>
|
||||
<code class="small">css/theme.scss + theme.png</code>
|
||||
<small class="text-muted d-block">Kleuren/hoogtes in SCSS, een <code>theme.png</code> voorbeeld voor de selectie.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<?php foreach ($themes as $themeName => $themeData): ?>
|
||||
<?php $isActive = $themeName === $activeTheme; ?>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100 theme-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-2">
|
||||
<?php $preview = '/themes/' . htmlspecialchars($themeName) . '/theme.png'; ?>
|
||||
<img src="<?= $preview ?>" alt="Voorbeeld thema <?= htmlspecialchars($themeData['title'] ?? $themeData['name'] ?? $themeName) ?>" class="img-fluid rounded theme-preview">
|
||||
</div>
|
||||
<h5 class="card-title mb-0">
|
||||
<?= htmlspecialchars($themeData['title'] ?? $themeData['name'] ?? $themeName) ?>
|
||||
</h5>
|
||||
<?php if ($isActive): ?>
|
||||
<span class="badge bg-success mt-1">Actief</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-footer text-center py-2">
|
||||
<?php if ($isActive): ?>
|
||||
<span class="text-muted small">Dit thema is actief</span>
|
||||
<?php else: ?>
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="action" value="activate">
|
||||
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success" title="Activeren">
|
||||
<i class="bi bi-check-circle"></i> Activeren
|
||||
</button>
|
||||
</form>
|
||||
<?php if ($themeName !== 'default'): ?>
|
||||
<form method="POST" action="/admin/theme-delete?theme=<?= urlencode($themeName) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je thema '<?= htmlspecialchars($themeData['title'] ?? $themeName) ?>' wilt verwijderen? Alle bestanden worden permanent verwijderd.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-card {
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.theme-card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.theme-preview {
|
||||
border: 1px solid #dee2e6;
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-cloud-arrow-down"></i> Systeem Update</h2>
|
||||
|
||||
<?php if (isset($isGitWritable) && $isGitWritable === false): ?>
|
||||
<div class="alert alert-warning mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Schrijfrechten vereist voor Git:</strong> De PHP-webserver heeft geen schrijfrechten op de <code>.git/objects</code> map op de server.
|
||||
<br><br>
|
||||
Voer op de live server eenmalig uit in de terminal (als <code>root</code>):
|
||||
<pre class="bg-dark text-white p-2 rounded mt-2 mb-0 font-monospace">chown -R www-data:www-data /var/www/CodePress</pre>
|
||||
<small class="text-muted mt-1 d-block">(Vervang <code>www-data</code> door jouw webserver gebruiker, bijvoorbeeld <code>www</code> of <code>nginx</code>, als dat anders is).</small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($updateOutput)): ?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<i class="bi bi-terminal"></i> Update Resultaten
|
||||
</div>
|
||||
<div class="card-body bg-dark text-light p-3">
|
||||
<pre class="m-0 text-light" style="font-family: monospace; font-size: 0.9rem;"><?= htmlspecialchars($updateOutput) ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle"></i> Systeeminformatie
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<strong>Huidige CMS Versie:</strong>
|
||||
<span class="badge bg-primary ms-2"><?= htmlspecialchars($cmsVersion ?? '1.7.1') ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<strong>Configuratiestatus:</strong>
|
||||
<span class="badge bg-success ms-2"><i class="bi bi-check-circle"></i> Lokaal afgeschermd (Git-safe)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
Lokale configuratiebestanden (zoals <code>config.json</code> en <code>admin.json</code>) zijn uitgesloten van Git.
|
||||
Hierdoor blijven je instellingen, wachtwoorden en content veilig behouden tijdens het bijwerken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-arrow-repeat"></i> CodePress CMS Bijwerken
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Haal automatisch de nieuwste CMS updates op via het Git repository.</p>
|
||||
|
||||
<?php if (!empty($gitBranch)): ?>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Git branch: <code><?= htmlspecialchars($gitBranch) ?></code></small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/admin/update">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<button type="submit" class="btn btn-primary btn-lg" onclick="return confirm('Weet je zeker dat je het systeem wilt bijwerken naar de nieuwste versie?')">
|
||||
<i class="bi bi-cloud-download"></i> Systeem nu bijwerken
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
<h2 class="mb-4"><i class="bi bi-people"></i> Gebruikers</h2>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Users list -->
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header"><i class="bi bi-list"></i> Huidige gebruikers</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gebruikersnaam</th>
|
||||
<th>Rol</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th style="width: 200px;">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<?= htmlspecialchars($u['username']) ?>
|
||||
<?php if ($u['username'] === $user['username']): ?>
|
||||
<span class="badge bg-info">Jij</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><span class="badge bg-primary"><?= htmlspecialchars($u['role']) ?></span></td>
|
||||
<td class="text-muted"><?= htmlspecialchars($u['created']) ?></td>
|
||||
<td>
|
||||
<?php if ($u['username'] === $user['username']): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#changeOwnPasswordModal" title="Eigen wachtwoord wijzigen">
|
||||
<i class="bi bi-key"></i> Wachtwoord
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<!-- Change password (admin for other users) -->
|
||||
<form method="POST" action="/admin/users" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<input type="hidden" name="pw_username" value="<?= htmlspecialchars($u['username']) ?>">
|
||||
<div class="input-group input-group-sm d-inline-flex" style="width: auto;">
|
||||
<input type="password" name="new_password" placeholder="Nieuw ww" class="form-control form-control-sm" style="width: 100px;" required minlength="8">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="Wachtwoord wijzigen">
|
||||
<i class="bi bi-key"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="/admin/users" class="d-inline ms-1" onsubmit="return confirm('Weet je zeker dat je deze gebruiker wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="delete_username" value="<?= htmlspecialchars($u['username']) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change own password modal -->
|
||||
<div class="modal fade" id="changeOwnPasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="action" value="change_own_password">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-key"></i> Eigen wachtwoord wijzigen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Huidig wachtwoord</label>
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Nieuw wachtwoord</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||
<small class="form-text text-muted">Minimaal 8 tekens.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Bevestig nieuw wachtwoord</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
||||
<button type="submit" class="btn btn-warning"><i class="bi bi-check-lg"></i> Wachtwoord wijzigen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add user form -->
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-person-plus"></i> Gebruiker toevoegen</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required minlength="8">
|
||||
<small class="form-text text-muted">Minimaal 8 tekens.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Rol</label>
|
||||
<select class="form-select" id="role" name="role">
|
||||
<option value="admin">Admin</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus"></i> Toevoegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(S){var n={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},k=S.Pos;function y(e,t){return"pairs"==t&&"string"==typeof e?e:("object"==typeof e&&null!=e[t]?e:n)[t]}S.defineOption("autoCloseBrackets",!1,function(e,t,n){n&&n!=S.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),t&&(r(y(t,"pairs")),e.state.closeBrackets=t,e.addKeyMap(i))});var i={Backspace:function(e){var t=O(e);if(!t||e.getOption("disableInput"))return S.Pass;for(var n=y(t,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return S.Pass;var a=s(e,r[i].head);if(!a||n.indexOf(a)%2!=0)return S.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",k(o.line,o.ch-1),k(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=O(r),t=e&&y(e,"explode");if(!t||r.getOption("disableInput"))return S.Pass;for(var i=r.listSelections(),n=0;n<i.length;n++){if(!i[n].empty())return S.Pass;var a=s(r,i[n].head);if(!a||t.indexOf(a)%2!=0)return S.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),m(r,-1),i=r.listSelections();for(var t=0;t<i.length;t++){var n=i[t].head.line;r.indentLine(n,null,!0),r.indentLine(n+1,null,!0)}})}};function r(e){for(var t=0;t<e.length;t++){var n=e.charAt(t),r="'"+n+"'";i[r]||(i[r]=function(P){return function(e){var i=e,t=P,e=O(i);if(!e||i.getOption("disableInput"))return S.Pass;var n=y(e,"pairs"),r=n.indexOf(t);if(-1==r)return S.Pass;for(var a,o=y(e,"closeBefore"),s=y(e,"triples"),l=n.charAt(r+1)==t,c=i.listSelections(),h=r%2==0,f=0;f<c.length;f++){var u,d=c[f],p=d.head,g=i.getRange(p,k(p.line,p.ch+1));if(h&&!d.empty())u="surround";else if(!l&&h||g!=t)if(l&&1<p.ch&&0<=s.indexOf(t)&&i.getRange(k(p.line,p.ch-2),p)==t+t){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(k(p.line,p.ch-2))))return S.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(k(p.line,p.ch-1),p);if(S.isWordChar(g)||d==t||S.isWordChar(d))return S.Pass;u="both"}else{if(!h||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return S.Pass;u="both"}else u=l&&function(e,t){var n=e.getTokenAt(k(t.line,t.ch+1));return/\bstring/.test(n.type)&&n.start==t.ch&&(0==t.ch||!/\bstring/.test(e.getTokenTypeAt(t)))}(i,p)?"both":0<=s.indexOf(t)&&i.getRange(p,k(p.line,p.ch+3))==t+t+t?"skipThree":"skip";if(a){if(a!=u)return S.Pass}else a=u}var v=r%2?n.charAt(r-1):t,b=r%2?t:n.charAt(r+1);i.operation(function(){if("skip"==a)m(i,1);else if("skipThree"==a)m(i,3);else if("surround"==a){for(var e=i.getSelections(),t=0;t<e.length;t++)e[t]=v+e[t]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),t=0;t<e.length;t++)e[t]=(n=e[t],r=void 0,r=0<S.cmpPos(n.anchor,n.head),{anchor:new k(n.anchor.line,n.anchor.ch+(r?-1:1)),head:new k(n.head.line,n.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),m(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),m(i,1));var n,r})}}(n))}}function O(e){var t=e.state.closeBrackets;return t&&!t.override&&e.getModeAt(e.getCursor()).closeBrackets||t}function m(e,t){for(var n=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a],o=(o.head==e.getCursor()&&(i=a),o.head.ch||0<t?{line:o.head.line,ch:o.head.ch+t}:{line:o.head.line-1});n.push({anchor:o,head:o})}e.setSelections(n,i)}function s(e,t){e=e.getRange(k(t.line,t.ch-1),k(t.line,t.ch+1));return 2==e.length?e:null}r(n.pairs+"`")});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(i){"use strict";var a="CodeMirror-activeline",s="CodeMirror-activeline-background",c="CodeMirror-activeline-gutter";function l(e){for(var t=0;t<e.state.activeLines.length;t++)e.removeLineClass(e.state.activeLines[t],"wrap",a),e.removeLineClass(e.state.activeLines[t],"background",s),e.removeLineClass(e.state.activeLines[t],"gutter",c)}function o(t,e){for(var n=[],i=0;i<e.length;i++){var o=e[i],r=t.getOption("styleActiveLine");("object"==typeof r&&r.nonEmpty?o.anchor.line==o.head.line:o.empty())&&(r=t.getLineHandleVisualStart(o.head.line),n[n.length-1]!=r&&n.push(r))}!function(e,t){if(e.length==t.length){for(var n=0;n<e.length;n++)if(e[n]!=t[n])return;return 1}}(t.state.activeLines,n)&&t.operation(function(){l(t);for(var e=0;e<n.length;e++)t.addLineClass(n[e],"wrap",a),t.addLineClass(n[e],"background",s),t.addLineClass(n[e],"gutter",c);t.state.activeLines=n})}function r(e,t){o(e,t.ranges)}i.defineOption("styleActiveLine",!1,function(e,t,n){n=n!=i.Init&&n;t!=n&&(n&&(e.off("beforeSelectionChange",r),l(e),delete e.state.activeLines),t&&(e.state.activeLines=[],o(e,e.listSelections()),e.on("beforeSelectionChange",r)))})});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2078
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
||||
.CodeMirror {
|
||||
height: auto;
|
||||
min-height: 500px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-bottom: none;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.editor-toolbar .vr {
|
||||
height: 22px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.editor-toolbar .btn {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-wrapper .CodeMirror {
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-media-item {
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.card-media-item:hover {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Admin theme styles */
|
||||
|
||||
/* Code block styling (for guide pages) */
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e8e8e8;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
color: #d63384;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- < -->
|
||||
<path d="M8 8 L3 16 L8 24" stroke="#ffffff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- / -->
|
||||
<path d="M12 24 L18 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- .. -->
|
||||
<circle cx="22" cy="20" r="2" fill="#ffffff"/>
|
||||
<circle cx="28" cy="20" r="2" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 442 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 164 KiB |
@@ -0,0 +1,94 @@
|
||||
// Main application JavaScript
|
||||
// This file contains general application functionality
|
||||
|
||||
/**
|
||||
* Toggle sidebar visibility (open/close)
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
|
||||
const btn = document.querySelector('.sidebar-toggle-btn');
|
||||
const icon = btn ? btn.querySelector('i') : null;
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.classList.toggle('sidebar-hidden');
|
||||
|
||||
// Adjust content column width, toggle icon, and update aria-expanded
|
||||
if (sidebar.classList.contains('sidebar-hidden')) {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-lg-9', 'col-md-8');
|
||||
contentCol.classList.add('col-12');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar-inset');
|
||||
icon.classList.add('bi-layout-sidebar');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'true');
|
||||
} else {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-12');
|
||||
contentCol.classList.add('col-lg-9', 'col-md-8');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar');
|
||||
icon.classList.add('bi-layout-sidebar-inset');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sidebar state from sessionStorage on page load
|
||||
*/
|
||||
function restoreSidebarState() {
|
||||
if (sessionStorage.getItem('sidebarHidden') === 'true') {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
if (sidebar) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore sidebar state
|
||||
restoreSidebarState();
|
||||
|
||||
// Handle nested dropdowns for touch devices using event delegation
|
||||
document.addEventListener('click', function(e) {
|
||||
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
|
||||
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const submenu = toggle.closest('.dropdown-submenu');
|
||||
const dropdown = submenu.querySelector('.dropdown-menu');
|
||||
|
||||
// Close other submenus at the same level
|
||||
const parent = submenu.parentElement;
|
||||
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
||||
if (sibling !== submenu) {
|
||||
var siblingMenu = sibling.querySelector('.dropdown-menu');
|
||||
if (siblingMenu) siblingMenu.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current submenu
|
||||
if (dropdown) dropdown.classList.toggle('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all open submenus when clicking outside
|
||||
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach(function(menu) {
|
||||
menu.classList.remove('show');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,273 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var textarea = document.getElementById('editor-textarea');
|
||||
if (!textarea || typeof CodeMirror === 'undefined') return;
|
||||
|
||||
var ext = textarea.dataset.ext || 'md';
|
||||
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||
|
||||
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
|
||||
|
||||
var editor = CodeMirror.fromTextArea(textarea, {
|
||||
mode: modeMap[ext] || 'markdown',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true,
|
||||
styleActiveLine: true,
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
indentWithTabs: true,
|
||||
viewportMargin: Infinity,
|
||||
extraKeys: {
|
||||
'Ctrl-S': function () { if (form) form.submit(); },
|
||||
'Cmd-S': function () { if (form) form.submit(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Expose editor globally so other scripts can access it
|
||||
window.codeMirrorEditor = editor;
|
||||
|
||||
var commands = {
|
||||
md: [
|
||||
{ cmd: 'bold', icon: 'bi-type-bold', title: 'Vet' },
|
||||
{ cmd: 'italic', icon: 'bi-type-italic', title: 'Cursief' },
|
||||
{ cmd: 'heading', icon: 'bi-type-h2', title: 'Kop' },
|
||||
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
|
||||
null,
|
||||
{ cmd: 'ulist', icon: 'bi-list-ul', title: 'Ongenummerde lijst' },
|
||||
{ cmd: 'olist', icon: 'bi-list-ol', title: 'Genummerde lijst' },
|
||||
{ cmd: 'code', icon: 'bi-code-slash', title: 'Code' },
|
||||
{ cmd: 'quote', icon: 'bi-chat-quote', title: 'Citaat' },
|
||||
null,
|
||||
{ cmd: 'hr', icon: 'bi-hr', title: 'Horizontale lijn' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
],
|
||||
html: [
|
||||
{ cmd: 'strong', icon: 'bi-type-bold', title: '<strong>' },
|
||||
{ cmd: 'em', icon: 'bi-type-italic', title: '<em>' },
|
||||
null,
|
||||
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
|
||||
{ cmd: 'image', icon: 'bi-image', title: 'Afbeelding' },
|
||||
null,
|
||||
{ cmd: 'comment', icon: 'bi-chat-square-dots', title: 'Commentaar' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
],
|
||||
php: [
|
||||
{ cmd: 'comment_php', icon: 'bi-slash-circle', title: '// Commentaar' },
|
||||
{ cmd: 'docblock', icon: 'bi-blockquote-left', title: '/** DocBlock */' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
]
|
||||
};
|
||||
|
||||
function wrap(editor, before, after) {
|
||||
var sel = editor.getSelection();
|
||||
editor.replaceSelection(before + sel + after);
|
||||
if (!sel) {
|
||||
var cur = editor.getCursor();
|
||||
editor.setCursor({ line: cur.line, ch: cur.ch - after.length });
|
||||
}
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function insert(editor, text, cursorOffset) {
|
||||
editor.replaceSelection(text);
|
||||
if (cursorOffset !== undefined) {
|
||||
var cur = editor.getCursor();
|
||||
editor.setCursor({ line: cur.line, ch: cur.ch - text.length + cursorOffset });
|
||||
}
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function prependLine(editor, prefix) {
|
||||
var cur = editor.getCursor();
|
||||
editor.replaceRange(prefix, { line: cur.line, ch: 0 }, { line: cur.line, ch: 0 });
|
||||
editor.setCursor({ line: cur.line, ch: prefix.length });
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function execute(cmd) {
|
||||
var sel = editor.getSelection();
|
||||
|
||||
switch (cmd) {
|
||||
case 'bold':
|
||||
sel ? wrap(editor, '**', '**') : insert(editor, '****', 2);
|
||||
break;
|
||||
case 'italic':
|
||||
sel ? wrap(editor, '*', '*') : insert(editor, '**', 1);
|
||||
break;
|
||||
case 'heading':
|
||||
prependLine(editor, '## ');
|
||||
break;
|
||||
case 'link':
|
||||
sel ? wrap(editor, '[', '](url)') : insert(editor, '[linktekst](url)', 1);
|
||||
break;
|
||||
case 'ulist':
|
||||
prependLine(editor, '- ');
|
||||
break;
|
||||
case 'olist':
|
||||
prependLine(editor, '1. ');
|
||||
break;
|
||||
case 'code':
|
||||
sel ? wrap(editor, '`', '`') : insert(editor, '``', 1);
|
||||
break;
|
||||
case 'quote':
|
||||
prependLine(editor, '> ');
|
||||
break;
|
||||
case 'hr':
|
||||
insert(editor, '\n---\n', 0);
|
||||
break;
|
||||
case 'strong':
|
||||
sel ? wrap(editor, '<strong>', '</strong>') : insert(editor, '<strong></strong>', 8);
|
||||
break;
|
||||
case 'em':
|
||||
sel ? wrap(editor, '<em>', '</em>') : insert(editor, '<em></em>', 4);
|
||||
break;
|
||||
case 'image':
|
||||
insert(editor, '<img src="" alt="">', 10);
|
||||
break;
|
||||
case 'comment':
|
||||
sel ? wrap(editor, '<!-- ', ' -->') : insert(editor, '<!-- -->', 4);
|
||||
break;
|
||||
case 'comment_php':
|
||||
if (sel) {
|
||||
sel.indexOf('\n') !== -1
|
||||
? insert(editor, '/* ' + sel + ' */', 0)
|
||||
: insert(editor, '// ' + sel, 0);
|
||||
} else {
|
||||
prependLine(editor, '// ');
|
||||
}
|
||||
break;
|
||||
case 'docblock':
|
||||
insert(editor, '/**\n * \n */', 7);
|
||||
break;
|
||||
case 'media':
|
||||
var modal = new bootstrap.Modal(document.getElementById('mediaModal'));
|
||||
if (modal) modal.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolbar(ext) {
|
||||
var toolbar = document.getElementById('editor-toolbar');
|
||||
if (!toolbar) return;
|
||||
|
||||
toolbar.innerHTML = '';
|
||||
var cmds = commands[ext] || [];
|
||||
cmds.forEach(function (item) {
|
||||
if (item === null) {
|
||||
var sep = document.createElement('div');
|
||||
sep.className = 'vr';
|
||||
toolbar.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-secondary';
|
||||
btn.title = item.title;
|
||||
btn.innerHTML = '<i class="bi ' + item.icon + '"></i>';
|
||||
btn.addEventListener('click', function () { execute(item.cmd); });
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
var templates = {
|
||||
md: '# Nieuwe Pagina\n\nSchrijf hier je inhoud...\n',
|
||||
html: '<h1>Nieuwe Pagina</h1>\n<p>Dit is een HTML content pagina.</p>\n',
|
||||
php: '---\ntitle: Nieuwe Pagina\n---\n\n<?php\n$pageTitle = "Nieuwe Pagina";\n?>\n\n<h1><?= $pageTitle ?></h1>\n<p>PHP content - alles wat je hier echo't wordt weergegeven.</p>\n\n<?php\n$items = [\'Item 1\', \'Item 2\', \'Item 3\'];\n?>\n<ul>\n<?php foreach ($items as $item): ?>\n <li><?= $item ?></li>\n<?php endforeach; ?>\n</ul>\n'
|
||||
};
|
||||
|
||||
var defaultContents = {};
|
||||
|
||||
function getTemplate(ext) {
|
||||
return templates[ext] || '';
|
||||
}
|
||||
|
||||
function setDefaultContent(ext) {
|
||||
var tpl = getTemplate(ext);
|
||||
editor.setValue(tpl);
|
||||
editor.setCursor({ line: 0, ch: 0 });
|
||||
editor.focus();
|
||||
defaultContents[ext] = tpl;
|
||||
}
|
||||
|
||||
function switchMode(ext, forceContent) {
|
||||
var mode = modeMap[ext] || 'markdown';
|
||||
editor.setOption('mode', mode);
|
||||
textarea.dataset.ext = ext;
|
||||
|
||||
if (forceContent) {
|
||||
setDefaultContent(ext);
|
||||
} else {
|
||||
var cur = editor.getValue().trim();
|
||||
var expected = defaultContents[ext];
|
||||
if (cur === '' || (expected && cur === expected.trim())) {
|
||||
setDefaultContent(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editor.on('change', function () {
|
||||
if (window.__onContentChange) window.__onContentChange();
|
||||
});
|
||||
|
||||
buildToolbar(ext);
|
||||
|
||||
var form = document.getElementById('editor-form');
|
||||
var isNewPage = form && form.hasAttribute('data-new-page');
|
||||
|
||||
if (isNewPage && editor.getValue().trim() === '') {
|
||||
setDefaultContent(ext);
|
||||
}
|
||||
|
||||
var typeSelect = document.querySelector('[data-editor-mode]');
|
||||
if (typeSelect && isNewPage) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
switchMode(this.value, true);
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
editor.save();
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
|
||||
function handleShortcut(e) {
|
||||
var mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod || e.altKey) return;
|
||||
|
||||
var key = (e.key || '').toLowerCase();
|
||||
|
||||
if (key === 's') {
|
||||
e.preventDefault();
|
||||
editor.save();
|
||||
if (form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'n') {
|
||||
e.preventDefault();
|
||||
window.location.href = '/admin/content-new';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleShortcut);
|
||||
// CodeMirror swallows keystrokes inside the editor, so bind there too
|
||||
editor.setOption('extraKeys', Object.assign({}, editor.getOption('extraKeys') || {}, {
|
||||
'Ctrl-S': function () { handleShortcut({ ctrlKey: true, key: 's', preventDefault: function () {} }); },
|
||||
'Cmd-S': function () { handleShortcut({ metaKey: true, key: 's', preventDefault: function () {} }); }
|
||||
}));
|
||||
})();
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* KeyboardNavigation - WCAG 2.1 AA Compliant Keyboard Navigation
|
||||
*
|
||||
* Features:
|
||||
* - Full keyboard navigation support
|
||||
* - Focus management
|
||||
* - Skip links functionality
|
||||
* - Custom keyboard shortcuts
|
||||
* - Focus trap for modals
|
||||
* - WCAG 2.1 AA compliance
|
||||
*/
|
||||
class KeyboardNavigation {
|
||||
constructor() {
|
||||
this.focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
this.currentFocusIndex = -1;
|
||||
this.focusableElementsList = [];
|
||||
this.modalOpen = false;
|
||||
this.lastFocusedElement = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize keyboard navigation
|
||||
*/
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.setupSkipLinks();
|
||||
this.setupFocusManagement();
|
||||
this.setupKeyboardShortcuts();
|
||||
this.announceToScreenReader('Keyboard navigation initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for keyboard navigation
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||
document.addEventListener('focus', (e) => this.handleFocus(e), true);
|
||||
document.addEventListener('blur', (e) => this.handleBlur(e), true);
|
||||
|
||||
// Handle focus for dynamic content
|
||||
const observer = new MutationObserver(() => {
|
||||
this.updateFocusableElements();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['tabindex', 'disabled', 'aria-hidden']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleKeyDown(e) {
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
this.handleTabNavigation(e);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
this.handleActivation(e);
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleEscape(e);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
this.handleArrowNavigation(e);
|
||||
break;
|
||||
case 'Home':
|
||||
case 'End':
|
||||
this.handleHomeEndNavigation(e);
|
||||
break;
|
||||
default:
|
||||
this.handleCustomShortcuts(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Tab navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleTabNavigation(e) {
|
||||
if (e.ctrlKey || e.altKey) return;
|
||||
|
||||
this.updateFocusableElements();
|
||||
|
||||
if (this.focusableElementsList.length === 0) return;
|
||||
|
||||
const currentIndex = this.focusableElementsList.indexOf(document.activeElement);
|
||||
let nextIndex;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab - Previous element
|
||||
nextIndex = currentIndex <= 0 ? this.focusableElementsList.length - 1 : currentIndex - 1;
|
||||
} else {
|
||||
// Tab - Next element
|
||||
nextIndex = currentIndex >= this.focusableElementsList.length - 1 ? 0 : currentIndex + 1;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.focusElement(this.focusableElementsList[nextIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle activation (Enter/Space)
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleActivation(e) {
|
||||
const element = document.activeElement;
|
||||
|
||||
if (e.key === ' ' && (element.tagName === 'BUTTON' || element.role === 'button')) {
|
||||
e.preventDefault();
|
||||
element.click();
|
||||
this.announceToScreenReader('Button activated');
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && element.tagName === 'A' && element.getAttribute('role') === 'menuitem') {
|
||||
e.preventDefault();
|
||||
element.click();
|
||||
this.announceToScreenReader('Link activated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleEscape(e) {
|
||||
if (this.modalOpen) {
|
||||
this.closeModal();
|
||||
this.announceToScreenReader('Modal closed');
|
||||
} else {
|
||||
// Return focus to main content
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
this.focusElement(mainContent);
|
||||
this.announceToScreenReader('Returned to main content');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle arrow key navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleArrowNavigation(e) {
|
||||
const element = document.activeElement;
|
||||
|
||||
// Handle menu navigation
|
||||
if (element.getAttribute('role') === 'menuitem' || element.classList.contains('dropdown-item')) {
|
||||
e.preventDefault();
|
||||
this.navigateMenu(e.key);
|
||||
}
|
||||
|
||||
// Handle tab navigation in tab lists
|
||||
if (element.getAttribute('role') === 'tab') {
|
||||
e.preventDefault();
|
||||
this.navigateTabs(e.key);
|
||||
}
|
||||
|
||||
// Handle grid navigation
|
||||
if (element.getAttribute('role') === 'gridcell') {
|
||||
e.preventDefault();
|
||||
this.navigateGrid(e.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Home/End navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleHomeEndNavigation(e) {
|
||||
if (e.ctrlKey || e.altKey) return;
|
||||
|
||||
this.updateFocusableElements();
|
||||
|
||||
if (this.focusableElementsList.length === 0) return;
|
||||
|
||||
const targetIndex = e.key === 'Home' ? 0 : this.focusableElementsList.length - 1;
|
||||
|
||||
e.preventDefault();
|
||||
this.focusElement(this.focusableElementsList[targetIndex]);
|
||||
this.announceToScreenReader(`Moved to ${e.key === 'Home' ? 'first' : 'last'} element`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup skip links functionality
|
||||
*/
|
||||
setupSkipLinks() {
|
||||
const skipLinks = document.querySelectorAll('.skip-link');
|
||||
|
||||
skipLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetId = link.getAttribute('href').substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
this.focusElement(targetElement);
|
||||
this.announceToScreenReader(`Skipped to ${link.textContent}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup focus management
|
||||
*/
|
||||
setupFocusManagement() {
|
||||
// Add focus indicators
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:focus {
|
||||
outline: 3px solid #0056b3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
position: static !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
clip: auto !important;
|
||||
clip-path: none !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
[aria-hidden="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.keyboard-user *:focus {
|
||||
outline: 3px solid #0056b3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Detect keyboard user
|
||||
document.addEventListener('keydown', () => {
|
||||
document.body.classList.add('keyboard-user');
|
||||
}, { once: true });
|
||||
|
||||
// Remove keyboard class on mouse use
|
||||
document.addEventListener('mousedown', () => {
|
||||
document.body.classList.remove('keyboard-user');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup custom keyboard shortcuts
|
||||
*/
|
||||
setupKeyboardShortcuts() {
|
||||
// Alt+S - Focus search
|
||||
this.addShortcut('Alt+s', () => {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
this.focusElement(searchInput);
|
||||
this.announceToScreenReader('Search focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+N - Focus navigation
|
||||
this.addShortcut('Alt+n', () => {
|
||||
const navigation = document.getElementById('main-navigation');
|
||||
if (navigation) {
|
||||
this.focusElement(navigation.querySelector('[role="menuitem"]'));
|
||||
this.announceToScreenReader('Navigation focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+M - Focus main content
|
||||
this.addShortcut('Alt+m', () => {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
this.focusElement(mainContent);
|
||||
this.announceToScreenReader('Main content focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+H - Go home
|
||||
this.addShortcut('Alt+h', () => {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
// Alt+1-9 - Quick navigation
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
this.addShortcut(`Alt+${i}`, () => {
|
||||
this.quickNavigate(i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard shortcut
|
||||
*
|
||||
* @param {string} shortcut Shortcut combination
|
||||
* @param {Function} callback Callback function
|
||||
*/
|
||||
addShortcut(shortcut, callback) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (this.matchesShortcut(e, shortcut)) {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event matches shortcut
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
* @param {string} shortcut Shortcut string
|
||||
* @return {boolean} True if matches
|
||||
*/
|
||||
matchesShortcut(e, shortcut) {
|
||||
const parts = shortcut.toLowerCase().split('+');
|
||||
const key = parts.pop();
|
||||
|
||||
if (e.key.toLowerCase() !== key) return false;
|
||||
|
||||
const altRequired = parts.includes('alt');
|
||||
const ctrlRequired = parts.includes('ctrl');
|
||||
const shiftRequired = parts.includes('shift');
|
||||
|
||||
return e.altKey === altRequired &&
|
||||
e.ctrlKey === ctrlRequired &&
|
||||
e.shiftKey === shiftRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update focusable elements list
|
||||
*/
|
||||
updateFocusableElements() {
|
||||
this.focusableElementsList = Array.from(document.querySelectorAll(this.focusableElements))
|
||||
.filter(element => {
|
||||
// Filter out hidden elements
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
element.getAttribute('aria-hidden') !== 'true' &&
|
||||
!element.disabled;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus element with accessibility
|
||||
*
|
||||
* @param {Element} element Element to focus
|
||||
*/
|
||||
focusElement(element) {
|
||||
if (!element) return;
|
||||
|
||||
element.focus();
|
||||
|
||||
// Scroll into view if needed
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus events
|
||||
*
|
||||
* @param {FocusEvent} e Focus event
|
||||
*/
|
||||
handleFocus(e) {
|
||||
this.currentFocusIndex = this.focusableElementsList.indexOf(e.target);
|
||||
|
||||
// Announce focus changes to screen readers
|
||||
const announcement = this.getFocusAnnouncement(e.target);
|
||||
if (announcement) {
|
||||
setTimeout(() => {
|
||||
this.announceToScreenReader(announcement);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blur events
|
||||
*
|
||||
* @param {FocusEvent} e Blur event
|
||||
*/
|
||||
handleBlur(e) {
|
||||
// Store last focused element for modal restoration
|
||||
if (!this.modalOpen) {
|
||||
this.lastFocusedElement = e.target;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get focus announcement for screen readers
|
||||
*
|
||||
* @param {Element} element Focused element
|
||||
* @return {string} Announcement text
|
||||
*/
|
||||
getFocusAnnouncement(element) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const role = element.getAttribute('role');
|
||||
const label = element.getAttribute('aria-label') || element.textContent || '';
|
||||
|
||||
if (role === 'button') {
|
||||
return `Button, ${label}`;
|
||||
} else if (role === 'link') {
|
||||
return `Link, ${label}`;
|
||||
} else if (tagName === 'input') {
|
||||
const type = element.type || 'text';
|
||||
return `${type} input, ${label}`;
|
||||
} else if (role === 'menuitem') {
|
||||
return `Menu item, ${label}`;
|
||||
} else if (role === 'tab') {
|
||||
return `Tab, ${label}`;
|
||||
}
|
||||
|
||||
return label || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce to screen readers
|
||||
*
|
||||
* @param {string} message Message to announce
|
||||
*/
|
||||
announceToScreenReader(message) {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('role', 'status');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
|
||||
document.body.appendChild(announcement);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(announcement);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate menu with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateMenu(direction) {
|
||||
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
const currentIndex = menuItems.indexOf(document.activeElement);
|
||||
|
||||
let nextIndex;
|
||||
if (direction === 'ArrowDown' || direction === 'ArrowRight') {
|
||||
nextIndex = currentIndex >= menuItems.length - 1 ? 0 : currentIndex + 1;
|
||||
} else {
|
||||
nextIndex = currentIndex <= 0 ? menuItems.length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
this.focusElement(menuItems[nextIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate tabs with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateTabs(direction) {
|
||||
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
let nextIndex;
|
||||
if (direction === 'ArrowRight' || direction === 'ArrowDown') {
|
||||
nextIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1;
|
||||
} else {
|
||||
nextIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
this.focusElement(tabs[nextIndex]);
|
||||
tabs[nextIndex].click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate grid with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateGrid(direction) {
|
||||
// Implementation for grid navigation
|
||||
// This would need to be customized based on specific grid structure
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick navigation with Alt+number
|
||||
*
|
||||
* @param {number} number Number key
|
||||
*/
|
||||
quickNavigate(number) {
|
||||
const targets = [
|
||||
{ selector: '#main-navigation', name: 'navigation' },
|
||||
{ selector: '#search-input', name: 'search' },
|
||||
{ selector: '#main-content', name: 'main content' },
|
||||
{ selector: 'h1', name: 'heading' },
|
||||
{ selector: '.breadcrumb', name: 'breadcrumb' },
|
||||
{ selector: 'footer', name: 'footer' },
|
||||
{ selector: '.sidebar', name: 'sidebar' },
|
||||
{ selector: '.btn-primary', name: 'primary button' },
|
||||
{ selector: 'form', name: 'form' }
|
||||
];
|
||||
|
||||
if (number <= targets.length) {
|
||||
const target = targets[number - 1];
|
||||
const element = document.querySelector(target.selector);
|
||||
if (element) {
|
||||
this.focusElement(element);
|
||||
this.announceToScreenReader(`Quick navigation to ${target.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom shortcuts
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleCustomShortcuts(e) {
|
||||
// Additional custom shortcuts can be added here
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with focus trap
|
||||
*
|
||||
* @param {string} modalId Modal ID
|
||||
*/
|
||||
openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
|
||||
this.modalOpen = true;
|
||||
this.lastFocusedElement = document.activeElement;
|
||||
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Focus first focusable element in modal
|
||||
const firstFocusable = modal.querySelector(this.focusableElements);
|
||||
if (firstFocusable) {
|
||||
this.focusElement(firstFocusable);
|
||||
}
|
||||
|
||||
this.announceToScreenReader('Modal opened');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and restore focus
|
||||
*/
|
||||
closeModal() {
|
||||
if (!this.modalOpen) return;
|
||||
|
||||
const modal = document.querySelector('[role="dialog"][aria-hidden="false"]');
|
||||
if (!modal) return;
|
||||
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.style.display = 'none';
|
||||
|
||||
this.modalOpen = false;
|
||||
|
||||
// Restore focus to last focused element
|
||||
if (this.lastFocusedElement) {
|
||||
this.focusElement(this.lastFocusedElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize keyboard navigation when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.keyboardNavigation = new KeyboardNavigation();
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* ScreenReaderOptimization - WCAG 2.1 AA Compliant Screen Reader Support
|
||||
*
|
||||
* Features:
|
||||
* - Screen reader detection and optimization
|
||||
* - Live region management
|
||||
* - ARIA announcements
|
||||
* - Content adaptation for screen readers
|
||||
* - Voice control support
|
||||
* - WCAG 2.1 AA compliance
|
||||
*/
|
||||
class ScreenReaderOptimization {
|
||||
constructor() {
|
||||
this.isScreenReaderActive = false;
|
||||
this.liveRegion = null;
|
||||
this.announcementQueue = [];
|
||||
this.isAnnouncing = false;
|
||||
this.voiceControlEnabled = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize screen reader optimization
|
||||
*/
|
||||
init() {
|
||||
this.detectScreenReader();
|
||||
this.createLiveRegion();
|
||||
this.setupVoiceControl();
|
||||
this.optimizeContent();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if screen reader is active
|
||||
*/
|
||||
detectScreenReader() {
|
||||
// Multiple detection methods
|
||||
const methods = [
|
||||
this.detectByNavigator,
|
||||
this.detectByAria,
|
||||
this.detectByTiming,
|
||||
this.detectByBehavior
|
||||
];
|
||||
|
||||
let positiveDetections = 0;
|
||||
|
||||
methods.forEach(method => {
|
||||
if (method.call(this)) {
|
||||
positiveDetections++;
|
||||
}
|
||||
});
|
||||
|
||||
// Consider screen reader active if majority of methods detect it
|
||||
this.isScreenReaderActive = positiveDetections >= 2;
|
||||
|
||||
if (this.isScreenReaderActive) {
|
||||
document.body.classList.add('screen-reader-active');
|
||||
this.announceToScreenReader('Screen reader detected, accessibility features enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by navigator properties
|
||||
*/
|
||||
detectByNavigator() {
|
||||
// Check for common screen reader indicators
|
||||
return window.speechSynthesis !== undefined ||
|
||||
window.navigator.userAgent.includes('JAWS') ||
|
||||
window.navigator.userAgent.includes('NVDA') ||
|
||||
window.navigator.userAgent.includes('VoiceOver') ||
|
||||
window.navigator.userAgent.includes('TalkBack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by ARIA support
|
||||
*/
|
||||
detectByAria() {
|
||||
// Check if ARIA attributes are supported and used
|
||||
const testElement = document.createElement('div');
|
||||
testElement.setAttribute('role', 'region');
|
||||
testElement.setAttribute('aria-live', 'polite');
|
||||
|
||||
return testElement.getAttribute('role') === 'region' &&
|
||||
testElement.getAttribute('aria-live') === 'polite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by timing analysis
|
||||
*/
|
||||
detectByTiming() {
|
||||
// Screen readers often have different timing patterns
|
||||
const startTime = performance.now();
|
||||
|
||||
// Create a test element that screen readers would process differently
|
||||
const testElement = document.createElement('div');
|
||||
testElement.setAttribute('aria-hidden', 'false');
|
||||
testElement.textContent = 'Screen reader test';
|
||||
document.body.appendChild(testElement);
|
||||
|
||||
const endTime = performance.now();
|
||||
document.body.removeChild(testElement);
|
||||
|
||||
// If processing takes unusually long, might indicate screen reader
|
||||
return (endTime - startTime) > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by user behavior
|
||||
*/
|
||||
detectByBehavior() {
|
||||
// Check for keyboard-only navigation patterns
|
||||
let keyboardOnly = true;
|
||||
|
||||
document.addEventListener('mousedown', () => {
|
||||
keyboardOnly = false;
|
||||
}, { once: true });
|
||||
|
||||
// If user navigates with keyboard extensively, likely screen reader user
|
||||
setTimeout(() => {
|
||||
if (keyboardOnly) {
|
||||
this.isScreenReaderActive = true;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return false; // Async detection
|
||||
}
|
||||
|
||||
/**
|
||||
* Create live region for announcements
|
||||
*/
|
||||
createLiveRegion() {
|
||||
this.liveRegion = document.createElement('div');
|
||||
this.liveRegion.setAttribute('aria-live', 'polite');
|
||||
this.liveRegion.setAttribute('aria-atomic', 'true');
|
||||
this.liveRegion.className = 'sr-only live-region';
|
||||
this.liveRegion.style.position = 'absolute';
|
||||
this.liveRegion.style.left = '-10000px';
|
||||
this.liveRegion.style.width = '1px';
|
||||
this.liveRegion.style.height = '1px';
|
||||
this.liveRegion.style.overflow = 'hidden';
|
||||
|
||||
document.body.appendChild(this.liveRegion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup voice control
|
||||
*/
|
||||
setupVoiceControl() {
|
||||
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
|
||||
this.voiceControlEnabled = true;
|
||||
this.initializeVoiceRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize voice recognition
|
||||
*/
|
||||
initializeVoiceRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this.recognition = new SpeechRecognition();
|
||||
|
||||
this.recognition.continuous = false;
|
||||
this.recognition.interimResults = false;
|
||||
this.recognition.lang = document.documentElement.lang || 'nl-NL';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
const command = event.results[0][0].transcript.toLowerCase();
|
||||
this.handleVoiceCommand(command);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.log('Voice recognition error:', event.error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice commands
|
||||
*
|
||||
* @param {string} command Voice command
|
||||
*/
|
||||
handleVoiceCommand(command) {
|
||||
const commands = {
|
||||
'zoeken': () => this.focusSearch(),
|
||||
'navigatie': () => this.focusNavigation(),
|
||||
'hoofdinhoud': () => this.focusMainContent(),
|
||||
'home': () => this.goHome(),
|
||||
'terug': () => this.goBack(),
|
||||
'volgende': () => this.goNext(),
|
||||
'vorige': () => this.goPrevious(),
|
||||
'stop': () => this.stopReading()
|
||||
};
|
||||
|
||||
for (const [keyword, action] of Object.entries(commands)) {
|
||||
if (command.includes(keyword)) {
|
||||
action();
|
||||
this.announceToScreenReader(`Voice command: ${keyword}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize content for screen readers
|
||||
*/
|
||||
optimizeContent() {
|
||||
this.addMissingLabels();
|
||||
this.enhanceHeadings();
|
||||
this.improveTableAccessibility();
|
||||
this.optimizeImages();
|
||||
this.enhanceLinks();
|
||||
this.addLandmarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add missing labels to form elements
|
||||
*/
|
||||
addMissingLabels() {
|
||||
const inputs = document.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) {
|
||||
const id = input.id || 'input-' + Math.random().toString(36).substr(2, 9);
|
||||
input.id = id;
|
||||
|
||||
// Try to find associated label
|
||||
let label = document.querySelector(`label[for="${id}"]`);
|
||||
|
||||
if (!label) {
|
||||
// Create label from placeholder or name
|
||||
const labelText = input.placeholder || input.name || input.type || 'Input';
|
||||
label = document.createElement('label');
|
||||
label.textContent = labelText;
|
||||
label.setAttribute('for', id);
|
||||
label.className = 'sr-only';
|
||||
input.parentNode.insertBefore(label, input);
|
||||
}
|
||||
|
||||
input.setAttribute('aria-label', label.textContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance headings for better structure
|
||||
*/
|
||||
enhanceHeadings() {
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
// Add proper ARIA attributes
|
||||
heading.setAttribute('role', 'heading');
|
||||
heading.setAttribute('aria-level', heading.tagName.substring(1));
|
||||
|
||||
// Add unique ID for navigation
|
||||
if (!heading.id) {
|
||||
heading.id = 'heading-' + index;
|
||||
}
|
||||
|
||||
// Add heading anchor for navigation
|
||||
if (!heading.querySelector('.heading-anchor')) {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = '#' + heading.id;
|
||||
anchor.className = 'heading-anchor sr-only';
|
||||
anchor.textContent = 'Link to this heading';
|
||||
anchor.setAttribute('aria-label', 'Link to this heading');
|
||||
heading.appendChild(anchor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Improve table accessibility
|
||||
*/
|
||||
improveTableAccessibility() {
|
||||
const tables = document.querySelectorAll('table');
|
||||
|
||||
tables.forEach(table => {
|
||||
// Add table caption if missing
|
||||
if (!table.querySelector('caption')) {
|
||||
const caption = document.createElement('caption');
|
||||
caption.textContent = 'Tabel ' + (tables.indexOf(table) + 1);
|
||||
caption.className = 'sr-only';
|
||||
table.insertBefore(caption, table.firstChild);
|
||||
}
|
||||
|
||||
// Add scope to headers
|
||||
const headers = table.querySelectorAll('th');
|
||||
headers.forEach(header => {
|
||||
if (!header.getAttribute('scope')) {
|
||||
const scope = header.parentElement.tagName === 'THEAD' ? 'col' : 'row';
|
||||
header.setAttribute('scope', scope);
|
||||
}
|
||||
});
|
||||
|
||||
// Add table description
|
||||
if (!table.getAttribute('aria-describedby')) {
|
||||
const description = document.createElement('div');
|
||||
description.id = 'table-desc-' + Math.random().toString(36).substr(2, 9);
|
||||
description.className = 'sr-only';
|
||||
description.textContent = 'Data table with ' + headers.length + ' columns';
|
||||
table.parentNode.insertBefore(description, table);
|
||||
table.setAttribute('aria-describedby', description.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize images for screen readers
|
||||
*/
|
||||
optimizeImages() {
|
||||
const images = document.querySelectorAll('img');
|
||||
|
||||
images.forEach(img => {
|
||||
// Ensure alt text exists
|
||||
if (!img.alt && !img.getAttribute('aria-label')) {
|
||||
// Try to get alt text from nearby text
|
||||
const nearbyText = this.getNearbyText(img);
|
||||
img.alt = nearbyText || 'Afbeelding';
|
||||
img.setAttribute('role', 'img');
|
||||
}
|
||||
|
||||
// Add long description if needed
|
||||
if (img.title && !img.getAttribute('aria-describedby')) {
|
||||
const descId = 'img-desc-' + Math.random().toString(36).substr(2, 9);
|
||||
const description = document.createElement('div');
|
||||
description.id = descId;
|
||||
description.className = 'sr-only';
|
||||
description.textContent = img.title;
|
||||
img.parentNode.insertBefore(description, img.nextSibling);
|
||||
img.setAttribute('aria-describedby', descId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance links for screen readers
|
||||
*/
|
||||
enhanceLinks() {
|
||||
const links = document.querySelectorAll('a');
|
||||
|
||||
links.forEach(link => {
|
||||
// Ensure accessible name
|
||||
if (!link.textContent.trim() && !link.getAttribute('aria-label')) {
|
||||
const href = link.getAttribute('href') || '';
|
||||
link.setAttribute('aria-label', 'Link: ' + href);
|
||||
}
|
||||
|
||||
// Add external link indication
|
||||
if (link.hostname !== window.location.hostname) {
|
||||
if (!link.getAttribute('aria-label')?.includes('external')) {
|
||||
const currentLabel = link.getAttribute('aria-label') || link.textContent;
|
||||
link.setAttribute('aria-label', currentLabel + ' (externe link)');
|
||||
}
|
||||
}
|
||||
|
||||
// Add file type and size for file links
|
||||
const href = link.getAttribute('href');
|
||||
if (href && this.isFileLink(href)) {
|
||||
const fileInfo = this.getFileInfo(href);
|
||||
if (!link.getAttribute('aria-label')?.includes(fileInfo.type)) {
|
||||
const currentLabel = link.getAttribute('aria-label') || link.textContent;
|
||||
link.setAttribute('aria-label', currentLabel + ` (${fileInfo.type}, ${fileInfo.size})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add landmarks for better navigation
|
||||
*/
|
||||
addLandmarks() {
|
||||
// Add main landmark if missing
|
||||
if (!document.querySelector('[role="main"], main')) {
|
||||
const content = document.querySelector('article, .content, #content');
|
||||
if (content) {
|
||||
content.setAttribute('role', 'main');
|
||||
content.id = 'main-content';
|
||||
}
|
||||
}
|
||||
|
||||
// Add navigation landmark if missing
|
||||
if (!document.querySelector('[role="navigation"], nav')) {
|
||||
const nav = document.querySelector('.nav, .navigation, #navigation');
|
||||
if (nav) {
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'Hoofdmenu');
|
||||
}
|
||||
}
|
||||
|
||||
// Add search landmark if missing
|
||||
if (!document.querySelector('[role="search"]')) {
|
||||
const search = document.querySelector('.search, #search, [type="search"]');
|
||||
if (search) {
|
||||
search.setAttribute('role', 'search');
|
||||
search.setAttribute('aria-label', 'Zoeken');
|
||||
}
|
||||
}
|
||||
|
||||
// Add contentinfo landmark if missing
|
||||
if (!document.querySelector('[role="contentinfo"], footer')) {
|
||||
const footer = document.querySelector('footer');
|
||||
if (footer) {
|
||||
footer.setAttribute('role', 'contentinfo');
|
||||
footer.setAttribute('aria-label', 'Voettekst');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for dynamic content
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Monitor DOM changes for new content
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.optimizeNode(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Handle page changes
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(() => this.optimizeContent(), 100);
|
||||
});
|
||||
|
||||
// Handle AJAX content loading
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => this.optimizeContent(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a specific node
|
||||
*
|
||||
* @param {Node} node Node to optimize
|
||||
*/
|
||||
optimizeNode(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
// Optimize based on tag type
|
||||
switch (node.tagName.toLowerCase()) {
|
||||
case 'img':
|
||||
this.optimizeImages();
|
||||
break;
|
||||
case 'a':
|
||||
this.enhanceLinks();
|
||||
break;
|
||||
case 'table':
|
||||
this.improveTableAccessibility();
|
||||
break;
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
this.enhanceHeadings();
|
||||
break;
|
||||
case 'input':
|
||||
case 'select':
|
||||
case 'textarea':
|
||||
this.addMissingLabels();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nearby text for an element
|
||||
*
|
||||
* @param {Element} element Element to check
|
||||
* @return {string} Nearby text
|
||||
*/
|
||||
getNearbyText(element) {
|
||||
// Check parent text content
|
||||
let parent = element.parentElement;
|
||||
if (parent) {
|
||||
const text = parent.textContent.replace(element.alt || '', '').trim();
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
// Check previous sibling
|
||||
let prev = element.previousElementSibling;
|
||||
if (prev && prev.textContent.trim()) {
|
||||
return prev.textContent.trim();
|
||||
}
|
||||
|
||||
// Check next sibling
|
||||
let next = element.nextElementSibling;
|
||||
if (next && next.textContent.trim()) {
|
||||
return next.textContent.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if link is a file link
|
||||
*
|
||||
* @param {string} href Link href
|
||||
* @return {boolean} True if file link
|
||||
*/
|
||||
isFileLink(href) {
|
||||
const fileExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar'];
|
||||
return fileExtensions.some(ext => href.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file information
|
||||
*
|
||||
* @param {string} href File link
|
||||
* @return {object} File information
|
||||
*/
|
||||
getFileInfo(href) {
|
||||
const extension = href.split('.').pop().toLowerCase();
|
||||
const types = {
|
||||
'pdf': { type: 'PDF document', size: '' },
|
||||
'doc': { type: 'Word document', size: '' },
|
||||
'docx': { type: 'Word document', size: '' },
|
||||
'xls': { type: 'Excel spreadsheet', size: '' },
|
||||
'xlsx': { type: 'Excel spreadsheet', size: '' },
|
||||
'ppt': { type: 'PowerPoint presentation', size: '' },
|
||||
'pptx': { type: 'PowerPoint presentation', size: '' },
|
||||
'zip': { type: 'ZIP archive', size: '' },
|
||||
'rar': { type: 'RAR archive', size: '' }
|
||||
};
|
||||
|
||||
return types[extension] || { type: 'File', size: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce message to screen readers
|
||||
*
|
||||
* @param {string} message Message to announce
|
||||
* @param {string} priority Priority level
|
||||
*/
|
||||
announceToScreenReader(message, priority = 'polite') {
|
||||
if (!this.isScreenReaderActive) return;
|
||||
|
||||
// Queue announcement if currently announcing
|
||||
if (this.isAnnouncing) {
|
||||
this.announcementQueue.push({ message, priority });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAnnouncing = true;
|
||||
|
||||
// Create temporary live region if needed
|
||||
const tempRegion = document.createElement('div');
|
||||
tempRegion.setAttribute('aria-live', priority);
|
||||
tempRegion.setAttribute('aria-atomic', 'true');
|
||||
tempRegion.className = 'sr-only';
|
||||
tempRegion.textContent = message;
|
||||
|
||||
document.body.appendChild(tempRegion);
|
||||
|
||||
// Remove after announcement
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(tempRegion);
|
||||
this.isAnnouncing = false;
|
||||
|
||||
// Process next announcement in queue
|
||||
if (this.announcementQueue.length > 0) {
|
||||
const next = this.announcementQueue.shift();
|
||||
this.announceToScreenReader(next.message, next.priority);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice control methods
|
||||
*/
|
||||
startVoiceRecognition() {
|
||||
if (this.voiceControlEnabled && this.recognition) {
|
||||
this.recognition.start();
|
||||
this.announceToScreenReader('Voice control activated');
|
||||
}
|
||||
}
|
||||
|
||||
stopVoiceRecognition() {
|
||||
if (this.voiceControlEnabled && this.recognition) {
|
||||
this.recognition.stop();
|
||||
this.announceToScreenReader('Voice control deactivated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation methods for voice control
|
||||
*/
|
||||
focusSearch() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
this.announceToScreenReader('Search focused');
|
||||
}
|
||||
}
|
||||
|
||||
focusNavigation() {
|
||||
const navigation = document.querySelector('[role="navigation"]');
|
||||
if (navigation) {
|
||||
navigation.focus();
|
||||
this.announceToScreenReader('Navigation focused');
|
||||
}
|
||||
}
|
||||
|
||||
focusMainContent() {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
mainContent.focus();
|
||||
this.announceToScreenReader('Main content focused');
|
||||
}
|
||||
}
|
||||
|
||||
goHome() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
goNext() {
|
||||
// Implementation depends on context
|
||||
const nextButton = document.querySelector('.next, [aria-label*="next"]');
|
||||
if (nextButton) {
|
||||
nextButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
goPrevious() {
|
||||
// Implementation depends on context
|
||||
const prevButton = document.querySelector('.previous, [aria-label*="previous"]');
|
||||
if (prevButton) {
|
||||
prevButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
stopReading() {
|
||||
// Stop any ongoing screen reader activity
|
||||
window.speechSynthesis.cancel();
|
||||
this.announceToScreenReader('Reading stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize screen reader optimization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.screenReaderOptimization = new ScreenReaderOptimization();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "CodePress Admin Default",
|
||||
"type": "admin",
|
||||
"default_layout": "admin",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}CodePress Admin{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
||||
<style>
|
||||
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
|
||||
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
|
||||
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
||||
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
||||
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
||||
.admin-sidebar .nav-link i { width: 24px; text-align: center; margin-right: 0.5rem; }
|
||||
.admin-sidebar .nav-section { color: rgba(255,255,255,0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; padding: 1rem 1.25rem 0.3rem 1.25rem; }
|
||||
.admin-main { margin-left: 240px; padding: 2rem; }
|
||||
.admin-brand { color: #fff; padding: 1.25rem; font-size: 1.1rem; border-bottom: 1px solid rgba(255,255,255,0.15); }
|
||||
.admin-brand i { margin-right: 0.5rem; }
|
||||
.stat-card { border: none; border-radius: 0.5rem; }
|
||||
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
||||
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
||||
.admin-main { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
{% if needs_editor %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="admin-sidebar d-flex flex-column">
|
||||
<div class="admin-brand">
|
||||
<i class="bi bi-gear-fill"></i> CodePress Admin
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2">
|
||||
<li class="nav-section">Algemeen</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if has_permission('content') %}
|
||||
<li class="nav-section">Content</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route starts with 'content' ? 'active' : '' }}" href="/admin/content">
|
||||
<i class="bi bi-file-earmark-text"></i> Content
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('config') or has_permission('theme') or has_permission('security') %}
|
||||
<li class="nav-section">Instellingen</li>
|
||||
{% if has_permission('config') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'config' ? 'active' : '' }}" href="/admin/config">
|
||||
<i class="bi bi-sliders"></i> Configuratie
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('theme') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'theme' ? 'active' : '' }}" href="/admin/theme">
|
||||
<i class="bi bi-palette"></i> Thema
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('security') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'security' ? 'active' : '' }}" href="/admin/security">
|
||||
<i class="bi bi-shield-check"></i> Beveiliging
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('statistics') or has_permission('logs') %}
|
||||
<li class="nav-section">Gegevens</li>
|
||||
{% if has_permission('statistics') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'statistics' ? 'active' : '' }}" href="/admin/statistics">
|
||||
<i class="bi bi-bar-chart"></i> Statistieken
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('logs') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'logs' ? 'active' : '' }}" href="/admin/logs">
|
||||
<i class="bi bi-journal-text"></i> Logs
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('plugins') or has_permission('users') or has_permission('update') %}
|
||||
<li class="nav-section">Systeem</li>
|
||||
{% if has_permission('plugins') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'plugins' ? 'active' : '' }}" href="/admin/plugins">
|
||||
<i class="bi bi-plug"></i> Plugins
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('users') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'users' ? 'active' : '' }}" href="/admin/users">
|
||||
<i class="bi bi-people"></i> Gebruikers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('update') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'update' ? 'active' : '' }}" href="/admin/update">
|
||||
<i class="bi bi-cloud-arrow-down"></i> Update
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('guide') %}
|
||||
<li class="nav-section">Help</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'guide' ? 'active' : '' }}" href="/admin/guide">
|
||||
<i class="bi bi-book"></i> Handleiding
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="nav flex-column mt-auto mb-5">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/" target="_blank">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Website bekijken
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-warning" href="/admin/logout">
|
||||
<i class="bi bi-box-arrow-left"></i> Uitloggen
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="admin-user">
|
||||
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
|
||||
<br><small>{{ role_label(user_role)|default('') }}</small>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="admin-main">
|
||||
{% if message is defined and message %}
|
||||
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||
{% if needs_editor %}
|
||||
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/markdown.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/xml.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/css.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/javascript.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/htmlmixed.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/php.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/clike.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/addon/edit/closebrackets.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/addon/selection/active-line.min.js"></script>
|
||||
<script src="/admin/assets/js/editor-toolbar.js"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CodePress Admin - Login</title>
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||
<style>
|
||||
body { background-color: #f5f6fa; }
|
||||
.login-card { max-width: 400px; margin: 10vh auto; }
|
||||
@@ -20,14 +21,14 @@
|
||||
</div>
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 0 0 0.5rem 0.5rem;">
|
||||
<div class="card-body p-4">
|
||||
<?php if (!empty($error)): ?>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= htmlspecialchars($error) ?>
|
||||
{{ error }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/login">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||
<div class="input-group">
|
||||
@@ -55,6 +56,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Configuratie - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
|
||||
|
||||
<form method="POST" action="/admin/config">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Algemene instellingen</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="site_title" class="form-label">Site titel</label>
|
||||
<input type="text" class="form-control" id="site_title" name="site_title" value="{{ config.site_title|default('CodePress') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="language_default" class="form-label">Standaard taal</label>
|
||||
<select class="form-select" id="language_default" name="language_default">
|
||||
<option value="nl" {{ config.language.default|default('nl') == 'nl' ? 'selected' : '' }}>Nederlands</option>
|
||||
<option value="en" {{ config.language.default == 'en' ? 'selected' : '' }}>Engels</option>
|
||||
<option value="de" {{ config.language.default == 'de' ? 'selected' : '' }}>Duits</option>
|
||||
<option value="fr" {{ config.language.default == 'fr' ? 'selected' : '' }}>Frans</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Auteur</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="author_name" class="form-label">Naam</label>
|
||||
<input type="text" class="form-control" id="author_name" name="author_name" value="{{ config.author.name|default('') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="author_email" class="form-label">E-mail</label>
|
||||
<input type="email" class="form-control" id="author_email" name="author_email" value="{{ config.author.email|default('') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Analytics & Logging</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" {{ config.analytics.enabled ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="analytics_enabled">Analytics ingeschakeld</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="logging_enabled" name="logging_enabled" {{ config.logging.enabled ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="logging_enabled">Logging ingeschakeld</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/dashboard" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Map hernoemen - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> Map hernoemen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="newname" class="form-label">Nieuwe naam voor {{ currentName }}</label>
|
||||
<input type="text" class="form-control" id="newname" name="newname" value="{{ currentName }}" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Hernoemen
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ fileName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-edit?file={{ file|url_encode }}" id="editor-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
|
||||
<span class="input-group-text">.{{ fileExt }}</span>
|
||||
</div>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
||||
</div>
|
||||
{% if isEditable %}
|
||||
<div class="col-md-4">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
{% for key, layoutFile in themeLayouts %}
|
||||
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if availablePlugins is not empty %}
|
||||
<div class="col-md-4">
|
||||
<label class="form-label d-block">Zichtbare plugins</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for plugin in availablePlugins %}
|
||||
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
||||
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if isEditable %}
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" title="Opslaan (Ctrl+S)">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
{% if isEditable %}
|
||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="Open in nieuw tabblad">
|
||||
<i class="bi bi-eye"></i> Preview
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var backBtn = document.getElementById('back-btn');
|
||||
var filenameInput = document.getElementById('filename');
|
||||
var layoutSelect = document.getElementById('layout');
|
||||
if (!backBtn) return;
|
||||
|
||||
var changed = false;
|
||||
|
||||
function markChanged() {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
||||
backBtn.classList.remove('btn-outline-secondary');
|
||||
backBtn.classList.add('btn-outline-danger');
|
||||
}
|
||||
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', markChanged);
|
||||
}
|
||||
|
||||
// Update the layout value in the editor's frontmatter when the select changes
|
||||
if (layoutSelect) {
|
||||
layoutSelect.addEventListener('change', function () {
|
||||
var newLayout = this.value;
|
||||
var editor = document.getElementById('editor-textarea');
|
||||
if (!editor) return;
|
||||
|
||||
var cm = window.codeMirrorEditor;
|
||||
var content = cm ? cm.getValue() : editor.value;
|
||||
|
||||
// Check if frontmatter exists
|
||||
var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
// Update existing layout line in frontmatter
|
||||
var frontmatter = fmMatch[1];
|
||||
if (/^layout:\s*.+$/m.test(frontmatter)) {
|
||||
frontmatter = frontmatter.replace(/^layout:\s*.+$/m, 'layout: ' + newLayout);
|
||||
} else {
|
||||
frontmatter = 'layout: ' + newLayout + '\n' + frontmatter;
|
||||
}
|
||||
content = content.replace(/^---\n([\s\S]*?)\n---/, '---\n' + frontmatter + '\n---');
|
||||
} else {
|
||||
// No frontmatter yet — add one
|
||||
content = '---\nlayout: ' + newLayout + '\n---\n\n' + content;
|
||||
}
|
||||
|
||||
if (cm) {
|
||||
cm.setValue(content);
|
||||
} else {
|
||||
editor.value = content;
|
||||
}
|
||||
|
||||
markChanged();
|
||||
});
|
||||
}
|
||||
|
||||
window.__onContentChange = markChanged;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ isDir ? 'Map' : 'Bestand' }} verplaatsen - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> {{ isDir ? 'Map' : 'Bestand' }} verplaatsen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
Verplaats <strong>{{ itemName }}</strong> naar:
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="destination" class="form-label">Doelmap</label>
|
||||
<select class="form-select" id="destination" name="destination" required>
|
||||
{% for directory in directories %}
|
||||
<option value="{{ directory }}">{{ directory }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Verplaatsen
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ itemDir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuwe pagina - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-new?dir={{ dir|url_encode }}" id="editor-form" data-new-page>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="extension" class="form-label">Bestandstype</label>
|
||||
<select class="form-select" id="extension" name="extension">
|
||||
{% for ext, label in availableExtensions %}
|
||||
<option value="{{ ext }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
{% for key, layoutFile in themeLayouts %}
|
||||
<option value="{{ key }}" {{ key == themeDefaultLayout ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,3 +1,8 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Content - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-file-earmark-text"></i> Content</h2>
|
||||
<div>
|
||||
@@ -7,7 +12,7 @@
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" data-bs-toggle="modal" data-bs-target="#createDirModal">
|
||||
<i class="bi bi-folder-plus"></i> Nieuwe map
|
||||
</button>
|
||||
<a href="/admin/content-new?dir=<?= urlencode($subdir) ?>" class="btn btn-primary btn-sm">
|
||||
<a href="/admin/content-new?dir={{ subdir|url_encode }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuw bestand
|
||||
</a>
|
||||
</div>
|
||||
@@ -16,8 +21,8 @@
|
||||
<div class="collapse mb-4" id="uploadForm">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content?dir=<?= urlencode($subdir) ?>" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<form method="POST" action="/admin/content?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
||||
@@ -31,37 +36,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($subdir)): ?>
|
||||
<?php
|
||||
$parentDir = dirname($subdir);
|
||||
$parentLink = $parentDir === '.' ? '' : $parentDir;
|
||||
?>
|
||||
{% if subdir %}
|
||||
{% set parentDir = subdir|split('/')|slice(0, -1)|join('/') %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-house"></i></a></li>
|
||||
<?php
|
||||
$crumbPath = '';
|
||||
foreach (explode('/', $subdir) as $i => $crumb):
|
||||
$crumbPath .= ($crumbPath ? '/' : '') . $crumb;
|
||||
?>
|
||||
<li class="breadcrumb-item <?= $crumbPath === $subdir ? 'active' : '' ?>">
|
||||
<?php if ($crumbPath === $subdir): ?>
|
||||
<?= htmlspecialchars($crumb) ?>
|
||||
<?php else: ?>
|
||||
<a href="/admin/content?dir=<?= urlencode($crumbPath) ?>"><?= htmlspecialchars($crumb) ?></a>
|
||||
<?php endif; ?>
|
||||
{% set crumbPath = '' %}
|
||||
{% for crumb in subdir|split('/') %}
|
||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ crumb : crumb %}
|
||||
<li class="breadcrumb-item {{ crumbPath == subdir ? 'active' : '' }}">
|
||||
{% if crumbPath == subdir %}
|
||||
{{ crumb }}
|
||||
{% else %}
|
||||
<a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="search" id="contentFilter" class="form-control border-start-0"
|
||||
placeholder="Filter op bestands- of mapnaam…" autocomplete="off" aria-label="Filter content">
|
||||
<input type="search" id="contentFilter" class="form-control border-start-0" placeholder="Filter op bestands- of mapnaam…" autocomplete="off" aria-label="Filter content">
|
||||
<span class="input-group-text bg-white text-muted" id="contentFilterCount"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,71 +76,64 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($items)): ?>
|
||||
{% if items is empty %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<tr data-name="<?= htmlspecialchars(mb_strtolower($item['name'])) ?>">
|
||||
{% else %}
|
||||
{% for item in items %}
|
||||
<tr data-name="{{ item.name|lower }}">
|
||||
<td>
|
||||
<?php if ($item['is_dir']): ?>
|
||||
<a href="/admin/content?dir=<?= urlencode($item['path']) ?>">
|
||||
<i class="bi bi-folder-fill text-warning"></i> <?= htmlspecialchars($item['name']) ?>
|
||||
{% if item.is_dir %}
|
||||
<a href="/admin/content?dir={{ item.path|url_encode }}">
|
||||
<i class="bi bi-folder-fill text-warning"></i> {{ item.name }}
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="/admin/content-edit?file=<?= urlencode($item['path']) ?>">
|
||||
<?php
|
||||
$icon = match($item['extension']) {
|
||||
'md' => 'bi-file-text text-primary',
|
||||
'php' => 'bi-file-code text-success',
|
||||
'html' => 'bi-file-earmark text-info',
|
||||
default => 'bi-file text-muted'
|
||||
};
|
||||
?>
|
||||
<i class="bi <?= $icon ?>"></i> <?= htmlspecialchars($item['name']) ?>
|
||||
{% else %}
|
||||
<a href="/admin/content-edit?file={{ item.path|url_encode }}">
|
||||
{% set icon = item.extension == 'md' ? 'bi-file-text text-primary' : (item.extension == 'php' ? 'bi-file-code text-success' : (item.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')) %}
|
||||
<i class="bi {{ icon }}"></i> {{ item.name }}
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item['is_dir']): ?>
|
||||
{% if item.is_dir %}
|
||||
<span class="badge bg-warning text-dark">Map</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary"><?= strtoupper($item['extension']) ?></span>
|
||||
<?php endif; ?>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ item.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted"><?= $item['size'] ?></td>
|
||||
<td class="text-muted"><?= $item['modified'] ?></td>
|
||||
<td class="text-muted">{{ item.size }}</td>
|
||||
<td class="text-muted">{{ item.modified }}</td>
|
||||
<td>
|
||||
<?php if ($item['is_dir']): ?>
|
||||
<a href="/admin/content-dir-rename?dir=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-secondary" title="Hernoemen">
|
||||
{% if item.is_dir %}
|
||||
<a href="/admin/content-dir-rename?dir={{ item.path|url_encode }}" class="btn btn-sm btn-outline-secondary" title="Hernoemen">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/admin/content-move?item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-dir-delete?dir=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<form method="POST" action="/admin/content-dir-delete?dir={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<a href="/admin/content-edit?file=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-primary" title="Bewerken">
|
||||
{% else %}
|
||||
<a href="/admin/content-edit?file={{ item.path|url_encode }}" class="btn btn-sm btn-outline-primary" title="Bewerken">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/admin/content-move?item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-delete?file=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<form method="POST" action="/admin/content-delete?file={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<tr id="contentFilterEmpty" class="d-none">
|
||||
<td colspan="5" class="text-muted text-center py-4">Geen resultaten voor deze filter.</td>
|
||||
</tr>
|
||||
@@ -173,9 +165,7 @@
|
||||
emptyRow.classList.toggle('d-none', shown > 0 || rows.length === 0);
|
||||
}
|
||||
if (counter) {
|
||||
counter.textContent = q === ''
|
||||
? rows.length + ' items'
|
||||
: shown + ' van ' + rows.length;
|
||||
counter.textContent = q === '' ? rows.length + ' items' : shown + ' van ' + rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +185,8 @@
|
||||
<div class="modal fade" id="createDirModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/content-dir-create?dir=<?= urlencode($subdir) ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
|
||||
<form method="POST" action="/admin/content-dir-create?dir={{ subdir|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> Nieuwe map aanmaken</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -216,3 +206,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+94
-56
@@ -1,20 +1,23 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Dashboard - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
|
||||
|
||||
<?php
|
||||
$aTotals = $analyticsSummary['totals'] ?? [];
|
||||
$aCountries = $analyticsSummary['countries'] ?? [];
|
||||
$topCountry = null;
|
||||
foreach ($aCountries as $cc => $cnt) {
|
||||
if ($cc !== 'UNKNOWN') { $topCountry = $cc; break; }
|
||||
}
|
||||
?>
|
||||
<div class="alert alert-light border mb-4">
|
||||
<strong>Welkom, {{ user.username }}</strong> — Ingelogd als <span class="badge bg-{{ user_role == 'admin' ? 'danger' : (user_role == 'content-manager' ? 'primary' : (user_role == 'bi-manager' ? 'success' : 'warning')) }}">{{ role_label(user_role) }}</span>
|
||||
</div>
|
||||
|
||||
{# Analytics stats - only for roles with statistics permission #}
|
||||
{% if has_permission('statistics') %}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Weergaven (30 dagen)</h6>
|
||||
<h3 class="mb-0"><?= number_format((int)($aTotals['views'] ?? 0), 0, ',', '.') ?></h3>
|
||||
<h3 class="mb-0">{{ (analytics_summary.totals.views ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
||||
</div>
|
||||
@@ -25,7 +28,7 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Unieke bezoekers (30 dagen)</h6>
|
||||
<h3 class="mb-0"><?= number_format((int)($aTotals['uniques'] ?? 0), 0, ',', '.') ?></h3>
|
||||
<h3 class="mb-0">{{ (analytics_summary.totals.uniques ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-people stat-icon text-success"></i>
|
||||
</div>
|
||||
@@ -37,8 +40,8 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Grootste land</h6>
|
||||
<h3 class="mb-0">
|
||||
<?= GeoIP::getCountryFlagEmoji($topCountry) ?>
|
||||
<span class="fs-5"><?= htmlspecialchars(GeoIP::getCountryName($topCountry)) ?></span>
|
||||
{{ get_country_flag(analytics_summary.countries|keys|first) }}
|
||||
<span class="fs-5">{{ get_country_name(analytics_summary.countries|keys|first) }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<a href="/admin/statistics" class="btn btn-sm btn-outline-secondary">Statistieken →</a>
|
||||
@@ -46,14 +49,17 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content stats - only for roles with content permission #}
|
||||
{% if has_permission('content') %}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Pagina's</h6>
|
||||
<h3 class="mb-0"><?= $stats['pages'] ?></h3>
|
||||
<h3 class="mb-0">{{ stats.pages }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-file-earmark-text stat-icon text-primary"></i>
|
||||
</div>
|
||||
@@ -64,53 +70,68 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Mappen</h6>
|
||||
<h3 class="mb-0"><?= $stats['directories'] ?></h3>
|
||||
<h3 class="mb-0">{{ stats.directories }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-folder stat-icon text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Plugins</h6>
|
||||
<h3 class="mb-0"><?= $stats['plugins'] ?></h3>
|
||||
</div>
|
||||
<i class="bi bi-plug stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Content grootte</h6>
|
||||
<h3 class="mb-0"><?= $stats['content_size'] ?></h3>
|
||||
<h3 class="mb-0">{{ stats.content_size }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-hdd stat-icon text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# System stats - only for admin/site-admin #}
|
||||
{% if has_permission('plugins') or has_permission('config') %}
|
||||
<div class="row g-4 mb-4">
|
||||
{% if has_permission('plugins') %}
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Plugins</h6>
|
||||
<h3 class="mb-0">{{ stats.plugins }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-plug stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
{# Site information - only for admin #}
|
||||
{% if has_permission('config') %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-info-circle"></i> Site informatie</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-muted">Site titel</td><td><?= htmlspecialchars($siteConfig['site_title'] ?? 'CodePress') ?></td></tr>
|
||||
<tr><td class="text-muted">Standaard taal</td><td><?= htmlspecialchars($siteConfig['language']['default'] ?? 'nl') ?></td></tr>
|
||||
<tr><td class="text-muted">Auteur</td><td><?= htmlspecialchars($siteConfig['author']['name'] ?? '-') ?></td></tr>
|
||||
<tr><td class="text-muted">CodePress versie</td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
|
||||
<tr><td class="text-muted">PHP versie</td><td><?= $stats['php_version'] ?></td></tr>
|
||||
<tr><td class="text-muted">Besturingssysteem</td><td><?= htmlspecialchars($stats['os']) ?></td></tr>
|
||||
<tr><td class="text-muted">Config geladen</td><td><?= $stats['config_exists'] ? '<span class="badge bg-success">Ja</span>' : '<span class="badge bg-danger">Nee</span>' ?></td></tr>
|
||||
<tr><td class="text-muted">Site titel</td><td>{{ site_config.site_title|default('CodePress') }}</td></tr>
|
||||
<tr><td class="text-muted">Standaard taal</td><td>{{ site_config.language.default|default('nl') }}</td></tr>
|
||||
<tr><td class="text-muted">Auteur</td><td>{{ site_config.author.name|default('-') }}</td></tr>
|
||||
<tr><td class="text-muted">CodePress versie</td><td>{{ stats.cms_version }}</td></tr>
|
||||
<tr><td class="text-muted">PHP versie</td><td>{{ stats.php_version }}</td></tr>
|
||||
<tr><td class="text-muted">Besturingssysteem</td><td>{{ stats.os }}</td></tr>
|
||||
<tr><td class="text-muted">Config geladen</td><td>{% if stats.config_exists %}<span class="badge bg-success">Ja</span>{% else %}<span class="badge bg-danger">Nee</span>{% endif %}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Recent activity - only for roles with logs permission #}
|
||||
{% if has_permission('logs') %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
@@ -118,20 +139,20 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
<a href="/admin/logs?tab=admin" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<?php if (empty($recentLogs)): ?>
|
||||
{% if recent_logs is empty %}
|
||||
<p class="text-muted mb-0">Geen activiteit geregistreerd.</p>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
<?php foreach ($recentLogs as $log): ?>
|
||||
{% for log in recent_logs %}
|
||||
<li class="mb-2 pb-2 border-bottom small">
|
||||
<span class="text-muted"><?= htmlspecialchars($log['time']) ?></span>
|
||||
<span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?> me-1"><?= htmlspecialchars($log['level']) ?></span>
|
||||
<code class="text-muted"><?= htmlspecialchars($log['ip']) ?></code>
|
||||
<?= htmlspecialchars($log['message']) ?>
|
||||
<span class="text-muted">{{ log.time }}</span>
|
||||
<span class="badge bg-{{ log.level == 'warning' ? 'warning text-dark' : (log.level == 'error' ? 'danger' : 'info') }} me-1">{{ log.level }}</span>
|
||||
<code class="text-muted">{{ log.ip }}</code>
|
||||
{{ log.message }}
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,40 +163,57 @@ foreach ($aCountries as $cc => $cnt) {
|
||||
<a href="/admin/logs?tab=requests" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<?php if (empty($recentRequests)): ?>
|
||||
{% if recent_requests is empty %}
|
||||
<p class="text-muted mb-0">Geen requests geregistreerd.</p>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
<?php foreach ($recentRequests as $log): ?>
|
||||
{% for log in recent_requests %}
|
||||
<li class="mb-2 pb-2 border-bottom small d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted me-1"><?= htmlspecialchars($log['time']) ?></span>
|
||||
<code class="text-muted me-1"><?= htmlspecialchars($log['ip']) ?></code>
|
||||
<span class="fw-bold"><?= htmlspecialchars($log['page']) ?></span>
|
||||
<span class="text-muted me-1">{{ log.time }}</span>
|
||||
<code class="text-muted me-1">{{ log.ip }}</code>
|
||||
<span class="fw-bold">{{ log.page }}</span>
|
||||
</div>
|
||||
<?php if (!empty($log['visitor_info'])): ?>
|
||||
<span class="badge bg-<?= $log['visitor_info']['badge'] ?>" title="<?= htmlspecialchars($log['ua']) ?>">
|
||||
<i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?>
|
||||
{% if log.visitor_info is not empty %}
|
||||
<span class="badge bg-{{ log.visitor_info.badge }}" title="{{ log.ua }}">
|
||||
<i class="bi {{ log.visitor_info.icon }}"></i> {{ log.visitor_info.label }}
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quick actions - role-specific #}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-lightning"></i> Snelle acties</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% if has_permission('content') %}
|
||||
<a href="/admin/content-new" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i> Nieuwe pagina</a>
|
||||
<a href="/admin/config" class="btn btn-outline-secondary"><i class="bi bi-sliders"></i> Configuratie bewerken</a>
|
||||
<a href="/admin/content" class="btn btn-outline-info"><i class="bi bi-folder2-open"></i> Content beheren</a>
|
||||
<a href="index.php" target="_blank" class="btn btn-outline-success"><i class="bi bi-box-arrow-up-right"></i> Website bekijken</a>
|
||||
{% endif %}
|
||||
{% if has_permission('config') %}
|
||||
<a href="/admin/config" class="btn btn-outline-secondary"><i class="bi bi-sliders"></i> Configuratie bewerken</a>
|
||||
{% endif %}
|
||||
{% if has_permission('statistics') %}
|
||||
<a href="/admin/statistics" class="btn btn-outline-secondary"><i class="bi bi-bar-chart"></i> Statistieken bekijken</a>
|
||||
{% endif %}
|
||||
{% if has_permission('theme') %}
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary"><i class="bi bi-palette"></i> Thema beheren</a>
|
||||
{% endif %}
|
||||
{% if has_permission('plugins') %}
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary"><i class="bi bi-plug"></i> Plugins beheren</a>
|
||||
{% endif %}
|
||||
<a href="/" target="_blank" class="btn btn-outline-success"><i class="bi bi-box-arrow-up-right"></i> Website bekijken</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ error_title|default('Fout') }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="text-center py-5">
|
||||
<h1 class="display-1 text-muted">{{ error_code|default('404') }}</h1>
|
||||
<h2 class="mb-3">{{ error_title|default('Pagina niet gevonden') }}</h2>
|
||||
<p class="text-muted mb-4">{{ error_message|default('De gevraagde pagina kon niet worden gevonden.') }}</p>
|
||||
<a href="/admin/dashboard" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Naar dashboard
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ guide_page ? 'Handleiding - ' : '' }}Handleiding{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% if guide_nav %}
|
||||
<aside class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-list-ul"></i> Navigatie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ guide_nav|raw }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="col-md-9">
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
{% endif %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item {{ not guide_page ? 'active' : '' }}">
|
||||
{% if guide_page %}
|
||||
<a href="/admin/guide{% if guide_lang %}?lang={{ guide_lang }}{% endif %}">Handleiding</a>
|
||||
{% else %}
|
||||
Handleidingen
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if guide_breadcrumbs %}
|
||||
{% for crumb in guide_breadcrumbs %}
|
||||
{% if loop.last %}
|
||||
<li class="breadcrumb-item active">{{ crumb.title }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="/admin/guide?lang={{ guide_lang }}&page={{ crumb.url }}">{{ crumb.title }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="guide-content card shadow-sm">
|
||||
<div class="card-body">
|
||||
{{ content|raw }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Logs - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/admin/logs" class="row g-3 align-items-end">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/logs?tab=admin" class="btn btn-sm {{ tab == 'admin' ? 'btn-primary' : 'btn-outline-primary' }}">
|
||||
<i class="bi bi-shield-check"></i> Admin
|
||||
</a>
|
||||
<a href="/admin/logs?tab=requests" class="btn btn-sm {{ tab == 'requests' ? 'btn-primary' : 'btn-outline-primary' }}">
|
||||
<i class="bi bi-globe"></i> Requests
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/admin/logs?tab={{ tab }}&download=1" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="sticky-top bg-white">
|
||||
<tr>
|
||||
<th>Tijd</th>
|
||||
<th>Level</th>
|
||||
<th>IP</th>
|
||||
<th>Bericht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ log.time }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ log.level == 'warning' ? 'warning text-dark' : (log.level == 'error' ? 'danger' : 'info') }}">
|
||||
{{ log.level }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ log.ip }}</td>
|
||||
<td><code class="text-muted">{{ log.message }}</code></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center py-4">Geen logs gevonden.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Media - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-images"></i> Media</h2>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse mb-4" id="uploadForm">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/media?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/*,video/*,audio/*">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
{% for item in items %}
|
||||
{% if not item.is_dir and item.extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] %}
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm">
|
||||
<img src="/content/{{ item.path|url_encode }}" class="card-img-top" alt="{{ item.name }}">
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate">{{ item.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<p class="text-muted text-center">Geen media bestanden gevonden.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugin Configuratie: {{ pluginName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugin Configuratie: {{ pluginName }}</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<textarea name="config" id="config-textarea" class="form-control font-monospace" rows="20">{{ pluginConfig }}</textarea>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugin bewerken: {{ pluginName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-pencil"></i> Plugin bewerken: {{ pluginName }}</h2>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Terug
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="php">{{ pluginContent }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuwe plugin - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Nieuwe plugin aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Plugin naam</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, underscores en streepjes.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugins - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-plug"></i> Plugins</h2>
|
||||
<a href="/admin/plugins-new" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuwe plugin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% for plugin in plugins %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">{{ plugin.name|default(plugin.name) }}</span>
|
||||
<span class="badge bg-{{ plugin.enabled ? 'success' : 'secondary' }}">{{ plugin.enabled ? 'Actief' : 'Inactief' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-2">{{ plugin.description|default('Geen beschrijving') }}</p>
|
||||
<p class="small text-muted mb-3">
|
||||
{% if plugin.version %}<span class="badge bg-info">v{{ plugin.version }}</span>{% endif %}
|
||||
{% if plugin.author %}<span class="badge bg-secondary">{{ plugin.author }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% if plugin.protected %}
|
||||
<span class="btn btn-sm btn-outline-secondary disabled" title="Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd">
|
||||
<i class="bi bi-shield-check"></i> Essentieel
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Bewerken
|
||||
</a>
|
||||
{% if plugin.hasConfig %}
|
||||
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-gear"></i> Config
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/plugins-toggle" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}">
|
||||
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i> {{ plugin.enabled ? 'Deactiveren' : 'Activeren' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze plugin wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> Geen plugins gevonden. Maak een nieuwe plugin aan om te beginnen.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Beveiliging - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-shield-check"></i> Beveiliging & Bot Bescherming</h2>
|
||||
|
||||
<form method="POST" action="/admin/security">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Bot Bescherming</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="botguard_enabled" name="botguard_enabled" {{ config.security.botguard_enabled ?? true ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="botguard_enabled">BotGuard ingeschakeld</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_bad_bots" name="block_bad_bots" {{ config.security.block_bad_bots ?? true ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="block_bad_bots">Blokkeer slechte bots</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Sessie Instellingen</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="session_timeout" class="form-label">Sessie timeout (seconden)</label>
|
||||
<input type="number" class="form-control" id="session_timeout" name="session_timeout" value="{{ config.security.session_timeout ?? 3600 }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="max_login_attempts" class="form-label">Maximale login pogingen</label>
|
||||
<input type="number" class="form-control" id="max_login_attempts" name="max_login_attempts" value="{{ config.security.max_login_attempts ?? 5 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/dashboard" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Statistieken - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Totaal aantal views</h6>
|
||||
<h3 class="mb-0">{{ (stats.totals.views ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
|
||||
<h3 class="mb-0">{{ (stats.totals.uniques ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-people stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Pagina views</h6>
|
||||
<h3 class="mb-0">{{ (stats.pages|length ?? 0) }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-file-earmark-text stat-icon text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-globe"></i> Landen</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
{% for country, count in stats.countries|slice(0, 10) %}
|
||||
<tr>
|
||||
<td>{{ get_country_flag(country) }} {{ get_country_name(country) }}</td>
|
||||
<td class="text-end">{{ count|number_format(0, ',', '.') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2" class="text-muted">Geen data</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-file-text"></i> Top pagina's</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
{% for page, count in stats.pages|slice(0, 10) %}
|
||||
<tr>
|
||||
<td><code class="text-muted">{{ page }}</code></td>
|
||||
<td class="text-end">{{ count|number_format(0, ',', '.') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2" class="text-muted">Geen data</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuw thema - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-palette"></i> Nieuw thema aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Thema naam</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, underscores en streepjes.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Thema's - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-palette"></i> Thema's</h2>
|
||||
<div>
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" name="activate_default" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check-lg"></i> Activeer Default
|
||||
</button>
|
||||
</form>
|
||||
<a href="/admin/theme-new" class="btn btn-outline-primary btn-sm ms-2">
|
||||
<i class="bi bi-plus-lg"></i> Nieuw thema
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% for theme in themes %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100 {{ theme.active ? 'border-primary border-2' : '' }}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">{{ theme.title|default(theme.name) }}</span>
|
||||
{% if theme.active %}
|
||||
<span class="badge bg-success">Actief</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Naam: {{ theme.name }}</p>
|
||||
{% if theme.default_layout %}
|
||||
<p class="text-muted small">Default layout: {{ theme.default_layout }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
{% if not theme.active %}
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="activate" value="{{ theme.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check-lg"></i> Activeren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="compile_scss" value="1">
|
||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-palette"></i> SCSS compileren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> Geen thema's gevonden.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Update - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-cloud-arrow-down"></i> Systeem Update</h2>
|
||||
|
||||
{% if isGitWritable == false %}
|
||||
<div class="alert alert-warning mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
De .git map is niet beschrijfbaar. Automatische updates zijn niet mogelijk.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Huidige versie</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">CodePress versie: <strong>{{ version }}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">Update opties</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||
<i class="bi bi-cloud-arrow-down"></i> Controleer op updates
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||
<i class="bi bi-arrow-repeat"></i> Update uitvoeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Gebruikers - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-people"></i> Gebruikers</h2>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list"></i> Gebruikers
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gebruikersnaam</th>
|
||||
<th>Rol</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for username, data in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
{{ username }}
|
||||
{% if username == user.username %}
|
||||
<span class="badge bg-info">Jij</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
||||
{{ data.role_label|default(data.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ data.created|default('Onbekend') }}</td>
|
||||
<td>
|
||||
{% if username != user.username %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#roleModal-{{ username }}">
|
||||
<i class="bi bi-person-gear"></i> Rol
|
||||
</button>
|
||||
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('Weet je zeker dat je gebruiker {{ username }} wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="delete_username" value="{{ username }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Role change modal -->
|
||||
<div class="modal fade" id="roleModal-{{ username }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="change_role">
|
||||
<input type="hidden" name="role_username" value="{{ username }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-person-gear"></i> Rol wijzigen: {{ username }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Huidige rol</label>
|
||||
<p><span class="badge bg-secondary">{{ data.role_label|default(data.role) }}</span></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_role-{{ username }}" class="form-label">Nieuwe rol</label>
|
||||
<select class="form-select" id="new_role-{{ username }}" name="new_role">
|
||||
{% for roleKey, roleLabel in roles %}
|
||||
<option value="{{ roleKey }}" {{ data.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Wijzigen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small">Eigen account</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center py-4">Geen gebruikers gevonden.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle"></i> Nieuwe gebruiker
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="mb-3">
|
||||
<label for="new_username" class="form-label">Gebruikersnaam</label>
|
||||
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||||
<small class="form-text text-muted">Minimaal 8 tekens.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_role" class="form-label">Rol</label>
|
||||
<select class="form-select" id="new_role" name="new_role">
|
||||
{% for roleKey, roleLabel in roles %}
|
||||
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg"></i> Gebruiker toevoegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user