Media browser: recursive scan entire content/ tree, /-media/ URL prefix, editor change detection fix

- handleMediaList() now scans content/ recursively for all media files
- URLs use /-media/ prefix mapping directly to content/ (no special cases)
- index.php: added /-media/ route, kept /-assets/ for backward compat
- editor-toolbar.js: fixed editor.on('change') placement (was inside switchMode)
- content-edit.php and content-new.php: back-btn unsaved-changes detection
- Removed unused __editorCleanup global
This commit is contained in:
2026-06-24 17:00:59 +02:00
parent d97c67c6a9
commit dc0d370e65
50 changed files with 2796 additions and 140 deletions

View File

@@ -13,7 +13,7 @@
## Project Structuur ## Project Structuur
``` ```
codepress/ codepress/
├── engine/ ├── cms/ # Core CMS engine
│ ├── core/ │ ├── core/
│ │ ├── class/ │ │ ├── class/
│ │ │ ├── CodePressCMS.php # Hoofd CMS class │ │ │ ├── CodePressCMS.php # Hoofd CMS class
@@ -25,16 +25,17 @@ codepress/
│ │ ├── config.php # Config loader (leest config.json) │ │ ├── config.php # Config loader (leest config.json)
│ │ └── index.php # Bootstrap (autoloader, requires) │ │ └── index.php # Bootstrap (autoloader, requires)
│ ├── lang/ # Taalbestanden (nl.php, en.php) │ ├── lang/ # Taalbestanden (nl.php, en.php)
── templates/ # Mustache templates ── templates/ # Mustache templates
├── layout.mustache # Hoofd layout (bevat inline CSS) ├── layout.mustache # Hoofd layout (bevat inline CSS)
├── assets/ ├── assets/
│ ├── header.mustache │ ├── header.mustache
│ ├── navigation.mustache │ ├── navigation.mustache
│ └── footer.mustache │ └── footer.mustache
├── markdown_content.mustache ├── markdown_content.mustache
├── php_content.mustache ├── php_content.mustache
└── html_content.mustache └── html_content.mustache
├── admin-console/ # Admin paneel │ └── router.php # PHP dev server router
├── admin/ # Admin paneel
│ ├── config/ │ ├── config/
│ │ ├── app.php # Admin app configuratie │ │ ├── app.php # Admin app configuratie
│ │ └── admin.json # Gebruikers & security (file-based) │ │ └── admin.json # Gebruikers & security (file-based)
@@ -48,10 +49,18 @@ codepress/
│ │ ├── content.php │ │ ├── content.php
│ │ ├── content-edit.php │ │ ├── content-edit.php
│ │ ├── content-new.php │ │ ├── content-new.php
│ │ ├── content-dir-form.php
│ │ ├── config.php │ │ ├── config.php
│ │ ├── plugins.php │ │ ├── plugins.php
│ │ ├── plugin-config.php
│ │ └── users.php │ │ └── users.php
│ └── storage/logs/ # Admin logs │ └── storage/logs/ # Admin logs
├── cli/ # CLI scripts & tests
│ └── test/
│ ├── accessibility.sh # WCAG 2.1 AA test suite
│ ├── enhanced-suite.sh # Enhanced test suite
│ ├── functional/ # Functionele testen
│ └── pentest/ # Penetratietesten
├── plugins/ # CMS plugins ├── plugins/ # CMS plugins
│ ├── HTMLBlock/ │ ├── HTMLBlock/
│ └── MQTTTracker/ │ └── MQTTTracker/
@@ -61,8 +70,8 @@ codepress/
│ └── admin.php # Admin entry point + router │ └── admin.php # Admin entry point + router
├── content/ # Content bestanden ├── content/ # Content bestanden
├── guide/ # Handleidingen (nl/en) ├── guide/ # Handleidingen (nl/en)
├── docs/ # Documentatie
├── config.json # Site configuratie ├── config.json # Site configuratie
├── TODO.md # Openstaande verbeteringen
└── AGENTS.md # Dit bestand └── AGENTS.md # Dit bestand
``` ```
@@ -70,12 +79,12 @@ codepress/
- **PHP Standards**: Follow PSR-12. Use 4 spaces for indentation. - **PHP Standards**: Follow PSR-12. Use 4 spaces for indentation.
- **Naming**: Classes `PascalCase` (e.g., `CodePressCMS`), methods `camelCase` (e.g., `renderMenu`), variables `camelCase`, config keys `snake_case`. - **Naming**: Classes `PascalCase` (e.g., `CodePressCMS`), methods `camelCase` (e.g., `renderMenu`), variables `camelCase`, config keys `snake_case`.
- **Architecture**: - **Architecture**:
- Core CMS logic in `engine/core/class/CodePressCMS.php` - Core CMS logic in `cms/core/class/CodePressCMS.php`
- Bootstrap/requires in `engine/core/index.php` - Bootstrap/requires in `cms/core/index.php`
- Configuration loaded from `config.json` via `engine/core/config.php` - Configuration loaded from `config.json` via `cms/core/config.php`
- Public website entry point: `public/index.php` - Public website entry point: `public/index.php`
- Admin entry point + routing: `public/admin.php` - Admin entry point + routing: `public/admin.php`
- Admin authenticatie: `admin-console/src/AdminAuth.php` - Admin authenticatie: `admin/src/AdminAuth.php`
- **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static). - **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
- **Templating**: Mustache-style `{{placeholder}}` in `templates/layout.mustache` via `SimpleTemplate.php`. - **Templating**: Mustache-style `{{placeholder}}` in `templates/layout.mustache` via `SimpleTemplate.php`.
- **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs. - **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs.
@@ -87,11 +96,11 @@ codepress/
- **Git**: `main` is the clean CMS core. `development` is de actieve development branch. `e.noorlander` bevat persoonlijke content. Niet mixen. - **Git**: `main` is the clean CMS core. `development` is de actieve development branch. `e.noorlander` bevat persoonlijke content. Niet mixen.
## Admin Console ## Admin Console
- **File-based**: Geen database. Gebruikers opgeslagen in `admin-console/config/admin.json` - **File-based**: Geen database. Gebruikers opgeslagen in `admin/config/admin.json`
- **Routing**: Via `?route=` parameter in `public/admin.php` - **Routing**: Via `?route=` parameter in `public/admin.php`
- **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `config`, `plugins`, `users` - **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `config`, `plugins`, `plugins-new`, `plugins-edit`, `plugins-config`, `plugins-toggle`, `plugins-delete`, `users`
- **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af - **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af
- **Templates**: Pure PHP templates in `admin-console/templates/pages/`. Layout in `layout.php` - **Templates**: Pure PHP templates in `admin/templates/pages/`. Layout in `layout.php`
## Important: Title vs File/Directory Name Logic ## Important: Title vs File/Directory Name Logic
- **CRITICAL**: When user asks for "title" corrections, they usually mean **FILE/DIRECTORY NAME WITHOUT LANGUAGE PREFIX AND EXTENSIONS**, not the HTML title from content! - **CRITICAL**: When user asks for "title" corrections, they usually mean **FILE/DIRECTORY NAME WITHOUT LANGUAGE PREFIX AND EXTENSIONS**, not the HTML title from content!
@@ -107,4 +116,4 @@ codepress/
- LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze. - LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze.
- Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features. - Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features.
- `vendor/` map bevat Composer dependencies (CommonMark, Mustache). Niet handmatig wijzigen. - `vendor/` map bevat Composer dependencies (CommonMark, Mustache). Niet handmatig wijzigen.
- `admin-console/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden. - `admin/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.

View File

@@ -30,7 +30,7 @@ A lightweight, file-based content management system built with PHP.
``` ```
codepress/ codepress/
├── engine/ ├── cms/ # Core CMS engine
│ ├── core/ │ ├── core/
│ │ ├── class/ │ │ ├── class/
│ │ │ ├── CodePressCMS.php # Main CMS class │ │ │ ├── CodePressCMS.php # Main CMS class
@@ -48,27 +48,27 @@ codepress/
│ ├── markdown_content.mustache │ ├── markdown_content.mustache
│ ├── php_content.mustache │ ├── php_content.mustache
│ └── html_content.mustache │ └── html_content.mustache
├── admin-console/ # Admin panel ├── admin/ # Admin panel
│ ├── config/ │ ├── config/
│ │ ├── app.php # Admin configuration │ │ ├── app.php # Admin configuration
│ │ └── admin.json # Users & security │ │ └── admin.json # Users & security
│ ├── src/ │ ├── src/
│ │ └── AdminAuth.php # Authentication service │ │ └── AdminAuth.php # Authentication service
│ └── templates/ # Admin templates │ └── templates/ # Admin templates
├── cli/ # CLI scripts & tests
│ └── test/
├── public/ # Web root ├── public/ # Web root
│ ├── assets/ │ ├── assets/
│ │ ├── css/ │ │ ├── css/
│ │ ├── js/ │ │ ├── js/
│ │ └── favicon.svg │ │ └── favicon.svg
│ ├── content/ # Content files
│ │ ├── nl.homepage.md # Dutch homepage
│ │ ├── en.homepage.md # English homepage
│ │ └── [lang].[page].md # Multi-language pages
│ ├── index.php # Website entry point │ ├── index.php # Website entry point
│ └── admin.php # Admin entry point │ └── admin.php # Admin entry point
├── content/ # Content files
├── plugins/ # CMS plugins
├── docs/ # Documentation
├── config.json # Configuration ├── config.json # Configuration
── version.php # Version tracking ── README.md
└── README.md # This file
``` ```
## ⚙️ Configuration ## ⚙️ Configuration

View File

@@ -30,7 +30,7 @@ Een lichtgewicht, file-based content management systeem gebouwd met PHP.
``` ```
codepress/ codepress/
├── engine/ ├── cms/ # Core CMS engine
│ ├── core/ │ ├── core/
│ │ ├── config.php # Configuratie loader │ │ ├── config.php # Configuratie loader
│ │ └── index.php # CMS engine │ │ └── index.php # CMS engine
@@ -43,18 +43,17 @@ codepress/
│ ├── markdown_content.mustache │ ├── markdown_content.mustache
│ ├── php_content.mustache │ ├── php_content.mustache
│ └── html_content.mustache │ └── html_content.mustache
├── content/ # Content bestanden ├── admin/ # Admin paneel
│ ├── map1/
│ │ ├── pagina1.md
│ │ └── pagina2.php
│ └── homepage.md
├── admin-console/ # Admin paneel
│ ├── config/ │ ├── config/
│ │ ├── app.php # Admin configuratie │ │ ├── app.php # Admin configuratie
│ │ └── admin.json # Gebruikers & security │ │ └── admin.json # Gebruikers & security
│ ├── src/ │ ├── src/
│ │ └── AdminAuth.php # Authenticatie service │ │ └── AdminAuth.php # Authenticatie service
│ └── templates/ # Admin templates │ └── templates/ # Admin templates
├── cli/ # CLI scripts & tests
│ └── test/
├── content/ # Content bestanden
├── plugins/ # CMS plugins
├── public/ # Web root ├── public/ # Web root
│ ├── assets/ │ ├── assets/
│ │ ├── css/ │ │ ├── css/
@@ -62,6 +61,7 @@ codepress/
│ │ └── favicon.svg │ │ └── favicon.svg
│ ├── index.php # Website entry point │ ├── index.php # Website entry point
│ └── admin.php # Admin entry point │ └── admin.php # Admin entry point
├── docs/ # Documentatie
├── config.json # Configuratie ├── config.json # Configuratie
└── README.md └── README.md
``` ```
@@ -74,7 +74,7 @@ codepress/
{ {
"site_title": "CodePress", "site_title": "CodePress",
"content_dir": "content", "content_dir": "content",
"templates_dir": "engine/templates", "templates_dir": "cms/templates",
"default_page": "auto", "default_page": "auto",
"homepage": "homepage", "homepage": "homepage",
"author": { "author": {

6
TODO.md Normal file
View File

@@ -0,0 +1,6 @@
# TODO
## ✅ Voltooid
- Elk thema zit in zijn eigen map in de thema's map (`themes/naam/theme.json`)
- In admin → content: alle bestanden kunnen hernoemd worden via de edit-pagina
- In admin → content: bestanden en mappen kunnen verplaatst worden

View File

@@ -12,6 +12,7 @@ return [
'content_dir' => __DIR__ . '/../../content/', 'content_dir' => __DIR__ . '/../../content/',
'config_json' => __DIR__ . '/../../config.json', 'config_json' => __DIR__ . '/../../config.json',
'plugins_dir' => __DIR__ . '/../../plugins/', 'plugins_dir' => __DIR__ . '/../../plugins/',
'assets_dir' => __DIR__ . '/../../content/-assets/',
'admin_config' => __DIR__ . '/admin.json', 'admin_config' => __DIR__ . '/admin.json',
'log_file' => __DIR__ . '/../storage/logs/admin.log', 'log_file' => __DIR__ . '/../storage/logs/admin.log',
]; ];

View File

@@ -137,6 +137,9 @@
case 'content-dir-rename': case 'content-dir-rename':
require __DIR__ . '/pages/content-dir-form.php'; require __DIR__ . '/pages/content-dir-form.php';
break; break;
case 'content-move':
require __DIR__ . '/pages/content-move-form.php';
break;
case 'users': case 'users':
require __DIR__ . '/pages/users.php'; require __DIR__ . '/pages/users.php';
break; break;

View File

@@ -0,0 +1,26 @@
<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.php?route=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>

View File

@@ -1,27 +1,309 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <h2 class="mb-4"><i class="bi bi-pencil"></i> <?= htmlspecialchars($fileName) ?></h2>
<h2><i class="bi bi-pencil"></i> <?= htmlspecialchars($fileName) ?></h2>
<a href="admin.php?route=content&dir=<?= urlencode(dirname($_GET['file'] ?? '')) ?>" 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 shadow-sm">
<div class="card-body"> <div class="card-body">
<form method="POST" action="admin.php?route=content-edit&file=<?= urlencode($_GET['file'] ?? '') ?>"> <form method="POST" action="admin.php?route=content-edit&file=<?= urlencode($file) ?>" id="editor-form">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="mb-3"> <div class="row mb-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="col-md-4">
<span class="badge bg-secondary"><?= strtoupper($fileExt) ?></span> <label for="filename" class="form-label">Bestandsnaam</label>
<small class="text-muted"><?= htmlspecialchars($_GET['file'] ?? '') ?></small> <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> </div>
<textarea name="content" class="form-control font-monospace" rows="25" style="font-size: 0.9rem; tab-size: 4;"><?= htmlspecialchars($fileContent) ?></textarea> <?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">
<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>
</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> </div>
<div class="d-flex gap-2"> <?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"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan <i class="bi bi-check-lg"></i> Opslaan
</button> </button>
<a href="admin.php?route=content&dir=<?= urlencode(dirname($_GET['file'] ?? '')) ?>" class="btn btn-outline-secondary">Annuleren</a> <a href="admin.php?route=content&dir=<?= urlencode(dirname($file)) ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
</div> </div>
</form> </form>
</div> </div>
</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.php?route=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 = '![' + f.name + '](' + f.url + ')';
} 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.php?route=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; ?>

View File

@@ -0,0 +1,34 @@
<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.php?route=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>

View File

@@ -1,13 +1,8 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <h2 class="mb-4"><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
<h2><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
<a href="admin.php?route=content&dir=<?= urlencode($dir ?? '') ?>" 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 shadow-sm">
<div class="card-body"> <div class="card-body">
<form method="POST" action="admin.php?route=content-new&dir=<?= urlencode($dir ?? '') ?>"> <form method="POST" action="admin.php?route=content-new&dir=<?= urlencode($dir ?? '') ?>" id="editor-form" data-new-page>
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-8"> <div class="col-md-8">
@@ -17,7 +12,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="type" class="form-label">Type</label> <label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type"> <select class="form-select" id="type" name="type" data-editor-mode>
<option value="md" selected>Markdown (.md)</option> <option value="md" selected>Markdown (.md)</option>
<option value="php">PHP (.php)</option> <option value="php">PHP (.php)</option>
<option value="html">HTML (.html)</option> <option value="html">HTML (.html)</option>
@@ -29,18 +24,286 @@
<small class="text-muted">Map: <?= htmlspecialchars($dir) ?></small> <small class="text-muted">Map: <?= htmlspecialchars($dir) ?></small>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="mb-3"> <div class="row mb-3">
<label for="content" class="form-label">Inhoud</label> <div class="col-md-6">
<textarea name="content" id="content" class="form-control font-monospace" rows="20" style="font-size: 0.9rem; tab-size: 4;" placeholder="# Mijn nieuwe pagina <label for="layout" class="form-label">Sjabloon / Layout</label>
<select class="form-select" id="layout" name="layout">
Schrijf hier je inhoud..."></textarea> <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>
</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>
<div class="d-flex gap-2"> <div class="editor-toolbar" id="editor-toolbar"></div>
<button type="submit" class="btn btn-primary"> <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 <i class="bi bi-check-lg"></i> Aanmaken
</button> </button>
<a href="admin.php?route=content&dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary">Annuleren</a> <a href="admin.php?route=content&dir=<?= urlencode($dir ?? '') ?>" class="btn btn-outline-secondary" id="back-btn">Terug</a>
</div> </div>
</form> </form>
</div> </div>
</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.php?route=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 = '![' + f.name + '](' + f.url + ')';
} 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.php?route=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>

View File

@@ -1,8 +1,34 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-file-earmark-text"></i> Content</h2> <h2><i class="bi bi-file-earmark-text"></i> Content</h2>
<a href="admin.php?route=content-new&dir=<?= urlencode($subdir) ?>" class="btn btn-primary btn-sm"> <div>
<i class="bi bi-plus-lg"></i> Nieuw bestand <button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
</a> <i class="bi bi-cloud-upload"></i> Upload
</button>
<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.php?route=content-new&dir=<?= urlencode($subdir) ?>" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Nieuw bestand
</a>
</div>
</div>
<div class="collapse mb-4" id="uploadForm">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="admin.php?route=content&dir=<?= urlencode($subdir) ?>" 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 naar deze map
</button>
</form>
</div>
</div>
</div> </div>
<?php if (!empty($subdir)): ?> <?php if (!empty($subdir)): ?>
@@ -39,7 +65,7 @@
<th>Type</th> <th>Type</th>
<th>Grootte</th> <th>Grootte</th>
<th>Gewijzigd</th> <th>Gewijzigd</th>
<th style="width: 120px;">Acties</th> <th style="width: 200px;">Acties</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -77,10 +103,26 @@
<td class="text-muted"><?= $item['size'] ?></td> <td class="text-muted"><?= $item['size'] ?></td>
<td class="text-muted"><?= $item['modified'] ?></td> <td class="text-muted"><?= $item['modified'] ?></td>
<td> <td>
<?php if (!$item['is_dir']): ?> <?php if ($item['is_dir']): ?>
<a href="admin.php?route=content-dir-rename&dir=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-secondary" title="Hernoemen">
<i class="bi bi-pencil"></i>
</a>
<a href="admin.php?route=content-move&item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="admin.php?route=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 ?>">
<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.php?route=content-edit&file=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-primary" title="Bewerken"> <a href="admin.php?route=content-edit&file=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-primary" title="Bewerken">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
<a href="admin.php?route=content-move&item=<?= urlencode($item['path']) ?>" class="btn btn-sm btn-outline-info" title="Verplaatsen">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="admin.php?route=content-delete&file=<?= urlencode($item['path']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')"> <form method="POST" action="admin.php?route=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 ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen"> <button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
@@ -96,3 +138,29 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Create Directory Modal -->
<div class="modal fade" id="createDirModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="admin.php?route=content-dir-create&dir=<?= urlencode($subdir) ?>">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<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>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="dirname" class="form-label">Mapnaam</label>
<input type="text" class="form-control" id="dirname" name="dirname" required autofocus>
<div class="form-text">Alleen letters, cijfers, punten, underscores en streepjes.</div>
</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> Aanmaken</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
<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.php?route=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.php?route=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;
}

View File

@@ -0,0 +1,28 @@
<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.php?route=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.php?route=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.php?route=plugins" class="btn btn-outline-secondary">Annuleren</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<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.php?route=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>

View File

@@ -1,4 +1,78 @@
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugins</h2> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-plug"></i> Plugins</h2>
<a href="admin.php?route=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)): ?> <?php if (empty($plugins)): ?>
<div class="alert alert-info">Geen plugins gevonden in de plugins map.</div> <div class="alert alert-info">Geen plugins gevonden in de plugins map.</div>
@@ -9,10 +83,10 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong> <strong><i class="bi bi-plug"></i> <?= htmlspecialchars($plugin['name']) ?></strong>
<?php if ($plugin['enabled']): ?> <?php if ($plugin['viewable']): ?>
<span class="badge bg-success">Actief</span> <span class="badge bg-success">Zichtbaar</span>
<?php else: ?> <?php else: ?>
<span class="badge bg-secondary">Inactief</span> <span class="badge bg-secondary">Systeem</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -36,6 +110,32 @@
</td> </td>
</tr> </tr>
</table> </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.php?route=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.php?route=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['viewable'] ? 'btn-outline-warning' : 'btn-outline-success' ?>" title="<?= $plugin['viewable'] ? 'Verbergen' : 'Tonen' ?>">
<i class="bi <?= $plugin['viewable'] ? 'bi-eye-slash' : 'bi-eye' ?>"></i> <?= $plugin['viewable'] ? 'Verberg' : 'Toon' ?>
</button>
</form>
<?php if ($plugin['has_config']): ?>
<a href="admin.php?route=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.php?route=plugins-delete&plugin=<?= urlencode($plugin['name']) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je de plugin &#39;<?= htmlspecialchars($plugin['name']) ?>&#39; 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> </div>
</div> </div>

View File

@@ -0,0 +1,283 @@
<?php if ($editTheme): ?>
<!-- Edit theme -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-palette"></i> Bewerk thema: <?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?></h2>
<a href="admin.php?route=theme" 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.php?route=theme" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="save">
<input type="hidden" name="theme" value="<?= htmlspecialchars($editThemeName) ?>">
<div class="row mb-3">
<div class="col-md-6">
<label for="theme_name" class="form-label">Themanaam</label>
<input type="text" class="form-control" id="theme_name" name="theme_name" value="<?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?>">
</div>
</div>
<div class="row g-4">
<div class="col-md-4">
<div class="card">
<div class="card-header">Header</div>
<div class="card-body">
<div class="mb-3">
<label for="header_color" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="header_color" name="header_color" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>" maxlength="7" data-target="header_color">
</div>
</div>
<div class="mb-0">
<label for="header_font_color" class="form-label">Tekst</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="header_font_color" name="header_font_color" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="header_font_color">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Navigatie</div>
<div class="card-body">
<div class="mb-3">
<label for="navigation_color" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="navigation_color" name="navigation_color" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>" maxlength="7" data-target="navigation_color">
</div>
</div>
<div class="mb-0">
<label for="navigation_font_color" class="form-label">Tekst</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="navigation_font_color" name="navigation_font_color" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="navigation_font_color">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Sidebar</div>
<div class="card-body">
<div class="mb-3">
<label for="sidebar_background" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="sidebar_background" name="sidebar_background" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>" maxlength="7" data-target="sidebar_background">
</div>
</div>
<div class="mb-0">
<label for="sidebar_border" class="form-label">Rand</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="sidebar_border" name="sidebar_border" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>" maxlength="7" data-target="sidebar_border">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mt-2">
<div class="col-md-4">
<div class="card">
<div class="card-header"><i class="bi bi-arrows-vertical"></i> Hoogte balken</div>
<div class="card-body">
<div class="mb-3">
<label for="header_height" class="form-label">Header hoogte (px)</label>
<input type="number" class="form-control" id="header_height" name="header_height" value="<?= htmlspecialchars($editTheme['header_height'] ?? '56') ?>" min="32" max="200">
</div>
<div class="mb-0">
<label for="nav_height" class="form-label">Navigatie hoogte (px)</label>
<input type="number" class="form-control" id="nav_height" name="nav_height" value="<?= htmlspecialchars($editTheme['nav_height'] ?? '42') ?>" min="24" max="200">
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header"><i class="bi bi-image"></i> Header achtergrond afbeelding</div>
<div class="card-body">
<div class="mb-3">
<label for="bg_image" class="form-label">Upload afbeelding</label>
<input type="file" class="form-control" id="bg_image" name="bg_image" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml">
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG</small>
</div>
<div class="mb-0">
<label for="background_image_url" class="form-label">Of URL naar afbeelding</label>
<div class="input-group">
<input type="text" class="form-control" id="background_image_url" name="background_image_url" placeholder="https://..." value="<?= htmlspecialchars(str_starts_with($editTheme['background_image'] ?? '', 'http') ? $editTheme['background_image'] : '') ?>">
</div>
<?php if (!empty($editTheme['background_image'])): ?>
<div class="mt-2 d-flex align-items-center gap-3">
<div>
<small class="text-muted">Huidig:</small>
<img src="<?= htmlspecialchars(str_starts_with($editTheme['background_image'], 'http') ? $editTheme['background_image'] : '/themes/' . $editTheme['background_image']) ?>" style="max-height: 60px; max-width: 200px;" class="img-thumbnail mt-1 d-block">
</div>
<div class="form-check">
<input class="btn-check" type="checkbox" id="bg_image_remove" name="bg_image_remove" value="1" autocomplete="off">
<label class="btn btn-outline-danger btn-sm" for="bg_image_remove">
<i class="bi bi-trash3"></i> Verwijder afbeelding
</label>
</div>
</div>
<?php endif; ?>
</div>
<div class="mb-3 mt-3">
<label for="background_image_opacity" class="form-label">Doorzichtigheid (%)</label>
<div class="d-flex align-items-center gap-2">
<input type="range" class="form-range" style="max-width: 200px;" id="background_image_opacity" name="background_image_opacity" min="0" max="100" value="<?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>" oninput="this.nextElementSibling.textContent=this.value+'%'">
<span class="badge bg-secondary"><?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>%</span>
</div>
<small class="form-text text-muted">100% = volledig zichtbaar, 50% = half doorzichtig, 0% = onzichtbaar</small>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan
</button>
<a href="admin.php?route=theme" class="btn btn-outline-secondary">Annuleren</a>
</div>
</form>
</div>
</div>
<?php else: ?>
<!-- Theme list -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-palette"></i> Thema's</h2>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#newThemeForm">
<i class="bi bi-plus-lg"></i> Nieuw thema
</button>
</div>
<div class="collapse mb-4" id="newThemeForm">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="admin.php?route=theme" class="row g-3 align-items-end">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="create">
<div class="col-md-6">
<label for="new_name" class="form-label">Naam nieuw thema</label>
<input type="text" class="form-control" id="new_name" name="new_name" placeholder="bijv. donker" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-plus-lg"></i> Aanmaken
</button>
</div>
</form>
</div>
</div>
</div>
<div class="row g-4">
<?php foreach ($themes as $themeName => $themeData): ?>
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0"><?= htmlspecialchars($themeData['name'] ?? $themeName) ?></h5>
<?php if ($themeName === $activeTheme): ?>
<span class="badge bg-success">Actief</span>
<?php endif; ?>
</div>
<!-- Color preview swatches -->
<div class="mb-3">
<div class="d-flex align-items-center gap-1 mb-2">
<span class="small text-muted" style="width: 70px;">Header:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?></span>
</div>
<div class="d-flex align-items-center gap-1 mb-2">
<span class="small text-muted" style="width: 70px;">Navigatie:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?></span>
</div>
<div class="d-flex align-items-center gap-1">
<span class="small text-muted" style="width: 70px;">Sidebar:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?></span>
</div>
</div>
<!-- Heights and background image -->
<div class="mb-3 small">
<div class="text-muted mb-1">
<i class="bi bi-arrows-vertical"></i> Header: <?= htmlspecialchars($themeData['header_height'] ?? '56') ?>px &middot; Navigatie: <?= htmlspecialchars($themeData['nav_height'] ?? '42') ?>px
</div>
<?php if (!empty($themeData['background_image'])): ?>
<div class="text-muted">
<i class="bi bi-image"></i> Achtergrond: <?= htmlspecialchars($themeData['background_image_opacity'] ?? '100') ?>% zichtbaar
<?php if (!str_starts_with($themeData['background_image'], 'http')): ?>
<img src="/themes/<?= htmlspecialchars($themeData['background_image']) ?>" style="max-height: 30px; max-width: 80px;" class="img-thumbnail ms-1 align-middle">
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="d-flex gap-2">
<a href="admin.php?route=theme&edit=<?= urlencode($themeName) ?>" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil"></i> Bewerken
</a>
<?php if ($themeName !== $activeTheme): ?>
<form method="POST" action="admin.php?route=theme">
<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-outline-success btn-sm">
<i class="bi bi-check-circle"></i> Activeren
</button>
</form>
<?php endif; ?>
<?php if ($themeName !== 'default'): ?>
<form method="POST" action="admin.php?route=theme">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Weet je zeker dat je thema &#39;<?= htmlspecialchars($themeData['name'] ?? $themeName) ?>&#39; wilt verwijderen?')">
<i class="bi bi-trash"></i> Verwijderen
</button>
</form>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
document.querySelectorAll('.form-control-color-value').forEach(function(input) {
input.addEventListener('input', function() {
var target = document.getElementById(this.dataset.target);
if (target && /^#[0-9a-fA-F]{6}$/.test(this.value)) {
target.value = this.value;
}
});
});
document.querySelectorAll('.form-control-color').forEach(function(input) {
input.addEventListener('input', function() {
var target = document.querySelector('[data-target="' + this.id + '"]');
if (target) target.value = this.value;
});
});
</script>

View File

@@ -333,7 +333,7 @@ This document outlines comprehensive functional tests for CodePress CMS to verif
**Steps:** **Steps:**
1. Try accessing `/content/` directly 1. Try accessing `/content/` directly
2. Try accessing `/engine/` files 2. Try accessing `/cms/` files
3. Try accessing `config.php` 3. Try accessing `config.php`
4. Try accessing `/vendor/` 4. Try accessing `/vendor/`

View File

@@ -133,7 +133,7 @@ test_vulnerability \
test_vulnerability \ test_vulnerability \
"Path traversal - config access" \ "Path traversal - config access" \
"$TARGET/?page=../engine/core/config" \ "$TARGET/?page=../cms/core/config" \
"content_dir" \ "content_dir" \
"true" "true"

View File

@@ -45,7 +45,7 @@ class CodePressCMS {
$this->currentLanguage = $this->getCurrentLanguage(); $this->currentLanguage = $this->getCurrentLanguage();
$this->translations = $this->loadTranslations($this->currentLanguage); $this->translations = $this->loadTranslations($this->currentLanguage);
// Initialize plugin manager (files already loaded in engine/core/index.php) // Initialize plugin manager (files already loaded in cms/core/index.php)
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins'); $this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
$api = new CMSAPI($this); $api = new CMSAPI($this);
$this->pluginManager->setAPI($api); $this->pluginManager->setAPI($api);
@@ -182,7 +182,10 @@ class CodePressCMS {
$result = []; $result = [];
foreach ($items as $item) { foreach ($items as $item) {
if ($item[0] === '.') continue; if ($item[0] === '.' || $item[0] === '-') continue;
// Skip assets directory (old name, kept for safety)
if ($item === 'assets' && is_dir($dir . '/' . $item)) continue;
// Skip language-specific content that doesn't match current language // Skip language-specific content that doesn't match current language
$availableLangs = array_keys($this->getAvailableLanguages()); $availableLangs = array_keys($this->getAvailableLanguages());
@@ -640,6 +643,12 @@ class CodePressCMS {
* @return string Formatted display name * @return string Formatted display name
*/ */
private function formatDisplayName($filename) { private function formatDisplayName($filename) {
// Preserve leading dash before processing
$hasLeadingDash = $filename[0] === '-';
if ($hasLeadingDash) {
$filename = substr($filename, 1);
}
// Remove language prefixes dynamically based on available languages // Remove language prefixes dynamically based on available languages
$availableLangs = array_keys($this->getAvailableLanguages()); $availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/'; $langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
@@ -656,11 +665,12 @@ class CodePressCMS {
'ict' => 'ICT', 'ict' => 'ICT',
]; ];
if (isset($specialCases[strtolower($filename)])) { if (isset($specialCases[strtolower($filename)])) {
return $specialCases[strtolower($filename)]; return ($hasLeadingDash ? '- ' : '') . $specialCases[strtolower($filename)];
} }
// Replace hyphens and underscores with spaces, then title case // Replace hyphens and underscores with spaces, then title case
$name = str_replace(['-', '_'], ' ', $filename); $name = str_replace(['-', '_'], ' ', $filename);
$name = trim($name);
$name = ucwords(strtolower($name)); $name = ucwords(strtolower($name));
// Post-process special cases in compound names // Post-process special cases in compound names
@@ -668,7 +678,7 @@ class CodePressCMS {
$name = str_ireplace(ucfirst($lower), $correct, $name); $name = str_ireplace(ucfirst($lower), $correct, $name);
} }
return $name; return ($hasLeadingDash ? '- ' : '') . $name;
} }
/** /**
@@ -983,8 +993,12 @@ private function getGuidePage() {
// Get homepage title // Get homepage title
$homepageTitle = $this->getHomepageTitle(); $homepageTitle = $this->getHomepageTitle();
// Get sidebar content from plugins // Get sidebar content from plugins (filtered per-page if specified in frontmatter)
$sidebarContent = $this->pluginManager->getSidebarContent(); $allowedPlugins = null;
if (!empty($page['metadata']['plugins'])) {
$allowedPlugins = array_map('trim', explode(',', $page['metadata']['plugins']));
}
$sidebarContent = $this->pluginManager->getSidebarContent($allowedPlugins);
// Get layout from page metadata // Get layout from page metadata
$layout = $page['layout'] ?? 'sidebar-content'; $layout = $page['layout'] ?? 'sidebar-content';
@@ -994,7 +1008,7 @@ private function getGuidePage() {
'site_title' => $this->config['site_title'], 'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']), 'page_title' => htmlspecialchars($page['title']),
'content' => $page['content'], 'content' => $this->processContent($page['content']),
'sidebar_content' => $sidebarContent, 'sidebar_content' => $sidebarContent,
'layout' => $layout, 'layout' => $layout,
'page_metadata' => $page['metadata'] ?? [], 'page_metadata' => $page['metadata'] ?? [],
@@ -1017,10 +1031,14 @@ private function getGuidePage() {
// Theme colors // Theme colors
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd', 'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff', 'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
'header_height' => $this->config['theme']['header_height'] ?? '56',
'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa', 'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa',
'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000', 'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000',
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa', 'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6', 'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
'background_image_css' => $this->getBackgroundImageCss(),
'background_image_opacity' => $this->getBackgroundImageOpacity(),
// Language // Language
'current_lang' => $this->currentLanguage, 'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage), 'current_lang_upper' => strtoupper($this->currentLanguage),
@@ -1286,4 +1304,27 @@ private function getGuidePage() {
} }
return false; return false;
} }
private function getBackgroundImageCss(): string
{
$bg = $this->config['theme']['background_image'] ?? '';
if (empty($bg)) {
return 'none';
}
if (str_starts_with($bg, 'http')) {
return 'url(' . $bg . ')';
}
return 'url(/themes/' . $bg . ')';
}
private function getBackgroundImageOpacity(): int
{
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
return max(0, min(100, $opacity));
}
private function processContent(string $content): string
{
return str_replace('-/assets/', '/-assets/', $content);
}
} }

View File

@@ -17,6 +17,17 @@ if (file_exists($configJsonPath)) {
$config['templates_dir'] = $projectRoot . $config['templates_dir']; $config['templates_dir'] = $projectRoot . $config['templates_dir'];
} }
// Load active theme
$activeTheme = $config['active_theme'] ?? 'default';
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
$themeFile = $themeDir . '/theme.json';
if (file_exists($themeFile)) {
$themeConfig = json_decode(file_get_contents($themeFile), true);
$config['theme'] = $themeConfig;
} else {
$config['theme'] = [];
}
return $config; return $config;
} }
} }

View File

@@ -16,7 +16,6 @@ class PluginManager
{ {
$this->api = $api; $this->api = $api;
// Inject API into all plugins that have setAPI method
foreach ($this->plugins as $plugin) { foreach ($this->plugins as $plugin) {
if (method_exists($plugin, 'setAPI')) { if (method_exists($plugin, 'setAPI')) {
$plugin->setAPI($api); $plugin->setAPI($api);
@@ -43,7 +42,6 @@ class PluginManager
if (class_exists($className)) { if (class_exists($className)) {
$this->plugins[$pluginName] = new $className(); $this->plugins[$pluginName] = new $className();
// Inject API if already available
if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) { if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) {
$this->plugins[$pluginName]->setAPI($this->api); $this->plugins[$pluginName]->setAPI($this->api);
} }
@@ -62,14 +60,49 @@ class PluginManager
return $this->plugins; return $this->plugins;
} }
public function getSidebarContent(): string public function isPluginViewable(object $plugin): bool
{
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
return !isset($config['viewable']) || $config['viewable'] !== false;
}
return true;
}
public function getSidebarContent(?array $allowedPlugins = null): string
{ {
$sidebarContent = ''; $sidebarContent = '';
foreach ($this->plugins as $plugin) { foreach ($this->plugins as $pluginName => $plugin) {
if (method_exists($plugin, 'getSidebarContent')) { if (!$this->isPluginViewable($plugin) || !method_exists($plugin, 'getSidebarContent')) {
$sidebarContent .= $plugin->getSidebarContent(); continue;
} }
// Filter by allowed plugins for this page
if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) {
continue;
}
$content = $plugin->getSidebarContent();
if (trim($content) === '') {
continue;
}
$title = 'Plugin';
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
$title = $config['title'] ?? 'Plugin';
}
$sidebarContent .= '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . htmlspecialchars($title) . '</h5>
</div>
<div class="card-body">
' . $content . '
</div>
</div>';
} }
return $sidebarContent; return $sidebarContent;

View File

@@ -1,4 +1,4 @@
<header class="navbar navbar-expand-lg navbar-dark" style="background-color: var(--header-bg);"> <header id="site-header" class="navbar navbar-expand-lg navbar-dark" style="background-color: transparent;">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="?page={{default_page}}&lang={{current_lang}}"> <a class="navbar-brand" href="?page={{default_page}}&lang={{current_lang}}">
<img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2"> <img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">

View File

@@ -76,11 +76,45 @@
<!-- Dynamic theme colors --> <!-- Dynamic theme colors -->
<style> <style>
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
#site-header {
background-image: {{background_image_css}};
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
#site-header::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--header-bg);
opacity: calc((100 - {{background_image_opacity}}) / 100);
pointer-events: none;
z-index: 0;
}
#site-header > * {
position: relative;
z-index: 1;
}
:root { :root {
--header-bg: {{header_color}}; --header-bg: {{header_color}};
--header-font: {{header_font_color}}; --header-font: {{header_font_color}};
--header-height: {{header_height}}px;
--nav-bg: {{navigation_color}}; --nav-bg: {{navigation_color}};
--nav-font: {{navigation_font_color}}; --nav-font: {{navigation_font_color}};
--nav-height: {{nav_height}}px;
--sidebar-bg: {{sidebar_background}}; --sidebar-bg: {{sidebar_background}};
--sidebar-border: {{sidebar_border}}; --sidebar-border: {{sidebar_border}};
} }
@@ -88,6 +122,7 @@
/* Header styles */ /* Header styles */
.navbar { .navbar {
background-color: var(--header-bg) !important; background-color: var(--header-bg) !important;
min-height: var(--header-height);
} }
.navbar .navbar-brand, .navbar .navbar-brand,
@@ -209,6 +244,7 @@
.navigation-section { .navigation-section {
background-color: var(--nav-bg) !important; background-color: var(--nav-bg) !important;
color: var(--nav-font) !important; color: var(--nav-font) !important;
min-height: var(--nav-height);
} }
/* Enhanced accessibility styles */ /* Enhanced accessibility styles */
@@ -290,7 +326,7 @@
border-right: 1px solid var(--sidebar-border) !important; border-right: 1px solid var(--sidebar-border) !important;
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; min-height: calc(100vh - var(--header-height) - var(--nav-height) - 42px);
} }
.sidebar { .sidebar {
@@ -310,7 +346,7 @@
/* Ensure full height layout */ /* Ensure full height layout */
.main-content { .main-content {
min-height: calc(100vh - 200px); flex: 1;
} }
/* Mobile responsive */ /* Mobile responsive */
@@ -380,9 +416,7 @@
</style> </style>
</head> </head>
<body> <body>
<header role="banner" id="site-header"> {{>header}}
{{>header}}
</header>
<nav role="navigation" aria-label="Main navigation" id="site-navigation"> <nav role="navigation" aria-label="Main navigation" id="site-navigation">
{{>navigation}} {{>navigation}}

View File

@@ -1,19 +1,15 @@
{ {
"site_title": "CodePress", "site_title": "CodePress",
"content_dir": "content", "content_dir": "content",
"templates_dir": "engine/templates", "templates_dir": "cms\/templates",
"default_page": "index", "default_page": "index",
"theme": { "active_theme": "default",
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6"
},
"language": { "language": {
"default": "nl", "default": "nl",
"available": ["nl", "en"] "available": [
"nl",
"en"
]
}, },
"seo": { "seo": {
"description": "CodePress CMS - Lightweight file-based content management system", "description": "CodePress CMS - Lightweight file-based content management system",
@@ -21,11 +17,11 @@
}, },
"author": { "author": {
"name": "E. Noorlander", "name": "E. Noorlander",
"website": "https://noorlander.info" "website": "https:\/\/noorlander.info"
}, },
"features": { "features": {
"auto_link_pages": true, "auto_link_pages": true,
"search_enabled": true, "search_enabled": true,
"breadcrumbs_enabled": true "breadcrumbs_enabled": true
} }
} }

View File

@@ -3,14 +3,14 @@
## Kritiek ## Kritiek
- [x] **Path traversal fix** - `str_replace('../')` in `getPage()` is te omzeilen. Gebruik `realpath()` met prefix-check (`CodePressCMS.php:313`) - [x] **Path traversal fix** - `str_replace('../')` in `getPage()` is te omzeilen. Gebruik `realpath()` met prefix-check (`CodePressCMS.php:313`)
- [x] **JWT secret fallback** - Standaard `'your-secret-key-change-in-production'` maakt tokens forgeable (`admin-console/config/app.php:11`) - [x] **JWT secret fallback** - Standaard `'your-secret-key-change-in-production'` maakt tokens forgeable (`admin/config/app.php:11`)
- [x] **executePhpFile() onveilig** - Open `include` wrapper zonder pad-restrictie (`CMSAPI.php:164`) - [x] **executePhpFile() onveilig** - Open `include` wrapper zonder pad-restrictie (`CMSAPI.php:164`)
- [ ] **Plugin auto-loading** - Elke map in `plugins/` wordt blind geladen zonder allowlist of validatie (`PluginManager.php:40`) - [ ] **Plugin auto-loading** - Elke map in `plugins/` wordt blind geladen zonder allowlist of validatie (`PluginManager.php:40`)
## Hoog ## Hoog
- [x] **IP spoofing** - `X-Forwarded-For` header wordt blind vertrouwd in MQTTTracker (`MQTTTracker.php:211`) - [x] **IP spoofing** - `X-Forwarded-For` header wordt blind vertrouwd in MQTTTracker (`MQTTTracker.php:211`)
- [x] **Debug hardcoded** - `'debug' => true` hardcoded in admin config (`admin-console/config/app.php:6`) - [x] **Debug hardcoded** - `'debug' => true` hardcoded in admin config (`admin/config/app.php:6`)
- [x] **Cookie security** - Cookies zonder `Secure`/`HttpOnly`/`SameSite` flags (`MQTTTracker.php:70`) - [x] **Cookie security** - Cookies zonder `Secure`/`HttpOnly`/`SameSite` flags (`MQTTTracker.php:70`)
- [ ] **autoLinkPageTitles()** - Regex kan geneste `<a>` tags produceren (`CodePressCMS.php:587`) - [ ] **autoLinkPageTitles()** - Regex kan geneste `<a>` tags produceren (`CodePressCMS.php:587`)
- [ ] **MQTT wachtwoord** - Credentials in plain text JSON (`MQTTTracker.php:37`) - [ ] **MQTT wachtwoord** - Credentials in plain text JSON (`MQTTTracker.php:37`)

View File

@@ -24,11 +24,6 @@ class HTMLBlock
$currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl'; $currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl';
$content = ' $content = '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . $this->config['title'] . '</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Huidige pagina:</strong> ' . htmlspecialchars($currentPage) . '</p> <p class="mb-2"><strong>Huidige pagina:</strong> ' . htmlspecialchars($currentPage) . '</p>
<p class="mb-2"><strong>Taal:</strong> ' . strtoupper($currentLang) . '</p> <p class="mb-2"><strong>Taal:</strong> ' . strtoupper($currentLang) . '</p>
<p class="mb-3"><strong>Homepage:</strong> ' . ($isHomepage ? 'Ja' : 'Nee') . '</p>'; <p class="mb-3"><strong>Homepage:</strong> ' . ($isHomepage ? 'Ja' : 'Nee') . '</p>';

View File

@@ -31,6 +31,7 @@ class MQTTTracker
$configFile = __DIR__ . '/config.json'; $configFile = __DIR__ . '/config.json';
$this->config = [ $this->config = [
'enabled' => true, 'enabled' => true,
'viewable' => false,
'broker_host' => 'localhost', 'broker_host' => 'localhost',
'broker_port' => 1883, 'broker_port' => 1883,
'client_id' => 'codepress_cms', 'client_id' => 'codepress_cms',

View File

@@ -1,7 +1,8 @@
{ {
"enabled": true, "enabled": true,
"broker_host": "mqtt.prive.noorlander.info", "viewable": false,
"broker_port": 1883, "broker_host": "mqtt.lan.noorlander.info",
"broker_port": "1883",
"client_id": "codepress_cms", "client_id": "codepress_cms",
"username": "", "username": "",
"password": "", "password": "",
@@ -10,6 +11,6 @@
"track_pages": true, "track_pages": true,
"track_performance": true, "track_performance": true,
"track_user_flows": true, "track_user_flows": true,
"session_timeout": 1800, "session_timeout": "1800",
"geoip_database_path": "/plugins/MQTTTracker/GeoLite2-Country.mmdb" "geoip_database_path": "\/plugins\/MQTTTracker\/GeoLite2-Country.mmdb"
} }

3
plugins/test/README.md Normal file
View File

@@ -0,0 +1,3 @@
# test
Dit is een test plugin

3
plugins/test/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"enabled": true
}

35
plugins/test/test.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
class test
{
private ?CMSAPI $api = null;
private array $config;
public function __construct()
{
$this->config = [
'title' => 'Dit is een test plugin',
'viewable' => true,
];
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
}
public function getSidebarContent(): string
{
return 'Hallo';
}
public function getConfig(): array
{
return $this->config;
}
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
}

View File

@@ -44,7 +44,13 @@
RewriteCond %{REQUEST_FILENAME} -f RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^assets/.*$ - [L] RewriteRule ^assets/.*$ - [L]
# Block direct access to all content files # Allow access to content assets
RewriteRule ^content/assets/.*$ - [L]
# Serve files from content/-assets/ via index.php
RewriteRule ^-assets/.*$ index.php [L]
# Block direct access to all other content files
RewriteRule ^content/.*$ - [F,L] RewriteRule ^content/.*$ - [F,L]
</IfModule> </IfModule>

File diff suppressed because it is too large Load Diff

View File

@@ -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+"`")});

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -0,0 +1,233 @@
(function () {
'use strict';
var textarea = document.getElementById('editor-textarea');
if (!textarea || typeof CodeMirror === 'undefined') return;
var ext = textarea.dataset.ext || 'md';
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 () { document.getElementById('editor-form').submit(); },
'Cmd-S': function () { document.getElementById('editor-form').submit(); }
}
});
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&#39;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);
});
}
document.getElementById('editor-form').addEventListener('submit', function () {
editor.save();
});
})();

View File

@@ -1,8 +1,8 @@
<?php <?php
require_once __DIR__ . '/../engine/core/index.php'; require_once __DIR__ . '/../cms/core/index.php';
$config = include __DIR__ . '/../engine/core/config.php'; $config = include __DIR__ . '/../cms/core/config.php';
// Security headers // Security headers
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
@@ -12,9 +12,78 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
header('Content-Security-Policy: default-src \'self\'; script-src \'self\' \'unsafe-inline\'; style-src \'self\' \'unsafe-inline\'; img-src \'self\' data:; font-src \'self\';'); header('Content-Security-Policy: default-src \'self\'; script-src \'self\' \'unsafe-inline\'; style-src \'self\' \'unsafe-inline\'; img-src \'self\' data:; font-src \'self\';');
header_remove('X-Powered-By'); header_remove('X-Powered-By');
// Block direct access to content files // Serve media files from any content/ subdirectory via /-media/
// Serve files from content/-assets/ via /-assets/ (backward compatible)
$requestUri = $_SERVER['REQUEST_URI'] ?? ''; $requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($requestUri, '/content/') !== false) { $parsedUrl = parse_url($requestUri);
$path = $parsedUrl['path'] ?? '';
$allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'zip', 'mp4', 'webm', 'ogg', 'mp3', 'wav'];
$mimeTypes = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
'pdf' => 'application/pdf', 'zip' => 'application/zip',
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg',
'mp3' => 'audio/mpeg', 'wav' => 'audio/wav',
'css' => 'text/css', 'js' => 'application/javascript',
];
if (strpos($path, '/-media/') === 0) {
$relative = ltrim(substr($path, 7), '/');
$root = realpath(__DIR__ . '/..');
$filePath = $root . '/content/' . $relative;
if (strpos($filePath, $root . '/content') === 0) {
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (in_array($ext, $allowedExt) && file_exists($filePath) && !is_dir($filePath)) {
if (isset($mimeTypes[$ext])) {
header('Content-Type: ' . $mimeTypes[$ext]);
}
readfile($filePath);
exit;
}
}
http_response_code(404);
echo '<h1>404 - Not Found</h1>';
exit;
}
if (strpos($path, '/-assets/') === 0) {
$relative = ltrim(substr($path, 8), '/');
$root = realpath(__DIR__ . '/..');
$servePath = null;
$candidates = [
$root . '/content/-assets/' . $relative,
$root . '/content/' . $relative,
];
foreach ($candidates as $candidate) {
if (strpos($candidate, $root . '/content') !== 0) continue;
if (file_exists($candidate) && !is_dir($candidate)) {
$ext = strtolower(pathinfo($candidate, PATHINFO_EXTENSION));
if (in_array($ext, $allowedExt)) {
$servePath = $candidate;
break;
}
}
}
if ($servePath !== null) {
$ext = strtolower(pathinfo($servePath, PATHINFO_EXTENSION));
if (isset($mimeTypes[$ext])) {
header('Content-Type: ' . $mimeTypes[$ext]);
}
readfile($servePath);
exit;
}
http_response_code(404);
echo '<h1>404 - Not Found</h1>';
exit;
}
// Block direct access to content files
if (strpos($path, '/content/') === 0) {
http_response_code(403); http_response_code(403);
echo '<h1>403 - Forbidden</h1><p>Access denied.</p>'; echo '<h1>403 - Forbidden</h1><p>Access denied.</p>';
exit; exit;

BIN
public/themes/test_bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

13
themes/default/theme.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "Standaard",
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"header_height": "56",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff",
"nav_height": "42",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"background_image": "",
"background_image_opacity": "100"
}

13
themes/test/theme.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "test",
"header_color": "#613583",
"header_font_color": "#ffffff",
"navigation_color": "#813d9c",
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"header_height": "120",
"nav_height": "50",
"background_image": "test_bg.jpg",
"background_image_opacity": 10
}