v2.6.1d (Lyra): Plugin i18n, plugin editor vernieuwd, media invoegen

Plugin internationalisatie: plugins hebben eigen language/ mappen. Systeem
plugins volgen admin taal (admin.php), content plugins volgen content taal
(site.php). Fallback chain: geselecteerd -> plugin default_language -> CMS
default. PluginManager/AdminPluginAPI/CMSAPI uitgebreid met
getPluginTranslations()/t(). plugin.json settings ondersteunen
label_key/help_key/option_label_key.

Plugin uniformiteit: alle 6 plugins hebben uniforme structuur (README.md,
assets/.gitkeep, language/nl|en/). plugins/README.md herschreven.
guide plugin-development.md (NL+EN) volledig herschreven.

Plugin editor vernieuwd: geneste bestandsbrowser zijbalk, nieuw bestand
aanmaken, uploaden naar assets/, verwijderen en verplaatsen. Nieuwe routes:
plugins-file-upload, plugins-file-delete, plugins-file-move. Path-traversal
bescherming + protected plugins geblokkeerd.

Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare
_media-modal.twig include. Plugin-context scant assets/ map. editor-toolbar.js
modeMap uitgebreid voor css/scss/js/json.

Plugin overzicht knoppen: alleen iconen met title/aria-label.

Tests: pentest 30/30, WCAG 2.1 AA 25/25.
This commit is contained in:
2026-08-18 13:49:52 +00:00
parent 10c72de859
commit 6348639da4
64 changed files with 3503 additions and 366 deletions
+30 -8
View File
@@ -210,12 +210,15 @@ codepress/
│ │ ├── partials/ # Header, navigation, footer
│ │ └── assets/ # SCSS, CSS, JS, img
├── plugins/ # Plugins
│ ├── HTMLBlock/ # Voorbeeld sidebar plugin
── Navigation/ # Essentiële navigatie plugin (beschermd)
├── Navigation.php # Plugin code
├── plugin.json # Plugin metadata
│ ├── assets/scss/ # Plugin SCSS bron
── assets/css/ # Plugin CSS
│ ├── Dashboard/ # Systeem plugin (admin taal)
── HTMLBlock/ # Content plugin (content taal)
│ ├── Navigation/ # Essentiële navigatie plugin (beschermd)
└── Statistics/ # Systeem plugin (admin taal)
│ ├── Statistics.php # Plugin code
── plugin.json # Plugin metadata + instellingen
│ ├── README.md # Plugin documentatie
│ ├── assets/ # Plugin CSS/JS/SCSS
│ └── language/ # Plugin vertalingen (nl/, en/)
├── content/ # Website content (.md, .php, .html)
├── public/ # Web root
│ ├── index.php # Website entry point
@@ -281,14 +284,33 @@ codepress/
### Plugin structuur
Elke plugin heeft een uniforme structuur:
```
plugins/MijnPlugin/
├── MijnPlugin.php # Plugin code (naam = pluginnaam)
├── plugin.json # Plugin metadata
├── plugin.json # Plugin metadata + instellingen
├── README.md # Plugin documentatie
├── assets/scss/ # Plugin SCSS bron
── assets/css/ # Plugin CSS (na compilatie)
── assets/css/ # Plugin CSS (na compilatie)
└── language/ # Plugin vertalingen (i18n)
├── nl/admin.php # NL admin labels (systeem plugins)
└── en/admin.php # EN admin labels
```
- **Systeem plugins** volgen de admin-taal (`language/<lang>/admin.php`)
- **Content plugins** volgen de content-taal (`language/<lang>/site.php`)
- Fallback chain: geselecteerde taal → plugin `default_language` → CMS site default
Zie `guide/nl/codepress-developer/plugin-development.md` voor uitgebreide documentatie.
### Plugin editor
De admin plugin-editor (`/admin/plugins-edit`) biedt een volledige bestandsbeheer-omgeving:
- Geneste bestandsbrowser zijbalk (alle bestanden in de plugin-map)
- Nieuw bestand aanmaken, uploaden naar assets/, verwijderen en verplaatsen
- CodeMirror editor voor .php, .json, .md, .html, .css, .scss, .js bestanden
### Essentiële plugins
De **Navigation** plugin is een essentiële plugin en kan niet worden gedeactiveerd, bewerkt of verwijderd. Deze plugin genereert automatisch de zijbalknavigatie voor handleidingen en content.
+31 -7
View File
@@ -1,15 +1,27 @@
# TODO
## Voor elke versie verhoging. Deze nooit weghalen.
- [ ] Pentest controles uitgevoerd
- [ ] WCAG 2.1 AA accessibility tests
- [ ] Volledige git commit maken.
- [ ] Verslag maken voor opdrcht gever
- [ ] Na convormatie versie verhogen
- [ ] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde.
- [x] Pentest controles uitgevoerd
- [x] WCAG 2.1 AA accessibility tests
- [x] Volledige git commit maken.
- [x] Verslag maken voor opdrcht gever
- [x] Na convormatie versie verhogen
- [x] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde.
## Te doen ⏳
## Bug fixes
- [x] admin rollen werkt niet goed, dashboard geeft een 404, de plugin's moeten aangege welke rol nodig is.
- [x] user admin mag nooit zijn eigen rol weizigen.
- [x] Na in loggen moet de admin zijn rol binnen de /admin kunnen veranderen om andere rollen te kunnen testen
- [x] in een andere rol kan de gebruiker niet zijn eigen wachtwoord veranderen.
- [x] Bij het aanmaken van een pagina in de content moet de auteur naam en email in de meta komen te staan. Deze moeten dus ook door middel van de api aan de twig bestanden doorgeven kunnen worden
- [x] Admin taal niet volledig geïntegreerd met plugins: plugins hebben nu eigen language/ mappen (nl/en). Systeem plugins volgen admin taal (admin.php), content plugins volgen content taal (site.php). Fallback chain: geselecteerd → plugin default_language → CMS default → leeg. PluginManager + AdminPluginAPI + CMSAPI uitgebreid met getPluginTranslations()/t(). plugin.json settings ondersteunen label_key/help_key/option_label_key.
- [x] Plugin uniformiteit: alle 6 plugins hebben nu een uniforme structuur (<Plugin>.php, plugin.json, README.md, assets/.gitkeep, language/nl|en/). plugins/README.md herschreven. guide plugin-development.md (NL+EN) volledig bijgewerkt met i18n + admin integratie + instellingen-schema. guide architectuur.md (NL+EN) bijgewerkt met plugin language/ map.
- [x] Plugin editor toont alleen de <Plugin>.php maar een plugin kan uit meerdere bestanden bestaan: plugins-edit pagina heeft nu een bestandsbrowser zijbalk die alle bestanden in de plugin-map toont (.php, .json, .md, .html, .css, .scss, .js) inclusief language/ en assets/ submappen. Nieuw-bestand knop met modal. Path-traversal bescherming via realpath + prefix check + segment-guard. scanPluginFiles() helper toegevoegd. editor-toolbar.js modeMap uitgebreid voor css/scss/js/json. Vertaalstrings toegevoegd aan nl/en/de admin.php.
- [x] Plugin editor uploaden en verwijderen: plugins-edit pagina heeft nu een Upload knop (bestanden naar assets/ van de plugin) en per-bestand verwijder-knop in de bestandsboom. Twee nieuwe routes: plugins-file-upload en plugins-file-delete. Path-traversal bescherming + protected plugins geblokkeerd + dotfiles geweigerd. Vertaalstrings toegevoegd.
- [x] Plugin editor bestand verplaatsen: plugins-edit pagina heeft nu per-bestand een verplaats-knop in de bestandsboom. Nieuwe route plugins-file-move + template plugins-move-form.twig (dropdown met mappen binnen de plugin, plugin root als optie, huidige map uitgesloten). Path-traversal bescherming + protected plugins geblokkeerd.
## Nice to have
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
- [ ] Fase 1: Config-resolutie
- [ ] For apache instants domein settings /public/noorlander.info/ or /public/mycode.name/
@@ -49,6 +61,18 @@
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
## v2.6.1d (2026-08-18) ✅
- [x] Pentest controles uitgevoerd (30/30)
- [x] WCAG 2.1 AA accessibility tests (25/25)
- [x] Plugin internationalisatie (i18n): plugins hebben eigen language/ mappen, systeem plugins volgen admin taal, content plugins volgen content taal, fallback chain
- [x] Plugin uniformiteit: alle 6 plugins hebben uniforme structuur (README.md, assets/.gitkeep, language/nl|en/)
- [x] Plugin editor vernieuwd: bestandsbrowser zijbalk (geneste boom), nieuw bestand aanmaken, uploaden, verwijderen, verplaatsen
- [x] Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare _media-modal.twig include; plugin-context scant assets/ map
- [x] Plugin overzicht knoppen: alleen iconen met title/aria-label
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt (plugin-development.md NL+EN volledig herschreven, architectuur.md NL+EN bijgewerkt)
- [x] Verslag gemaakt (docs/release-notes/v2.6.1d.md)
- [x] Versie verhoogd naar 2.6.1d
## v2.6.1c (2026-08-17) ✅
- [x] Pentest controles uitgevoerd (30/30)
- [x] WCAG 2.1 AA accessibility tests (25/25)
+63
View File
@@ -187,12 +187,71 @@ class AdminAuth
/**
* Get the role of the current user.
* Returns the override role if set (for testing), otherwise the real role.
*/
public function getCurrentRole(): string
{
if (isset($_SESSION['admin_role_override'])) {
return $_SESSION['admin_role_override'];
}
return $_SESSION['admin_role'] ?? 'admin';
}
/**
* Get the real role of the current user (ignoring any override).
*/
public function getRealRole(): string
{
return $_SESSION['admin_role'] ?? 'admin';
}
/**
* Check if the current user has an active role override.
*/
public function hasRoleOverride(): bool
{
return isset($_SESSION['admin_role_override']);
}
/**
* Temporarily switch to another role for testing purposes.
* Only admin users can do this; the override cannot grant more than admin.
*/
public function switchRole(string $role): array
{
if (!$this->isAuthenticated()) {
return ['success' => false, 'message' => 'Niet ingelogd.'];
}
// Only real admins can switch roles
$realRole = $this->getRealRole();
if ($realRole !== 'admin') {
return ['success' => false, 'message' => 'Alleen admins kunnen van rol wisselen.'];
}
if (!isset(self::ROLE_PERMISSIONS[$role])) {
return ['success' => false, 'message' => 'Ongeldige rol.'];
}
// Admins cannot switch to 'admin' (no point) — only to lower roles for testing
if ($role === 'admin') {
return ['success' => false, 'message' => 'Je bent al admin.'];
}
$_SESSION['admin_role_override'] = $role;
$this->log('info', "Rol-switch: {$_SESSION['admin_user']} -> {$role} (test)");
return ['success' => true, 'message' => 'Rol gewijzigd naar ' . self::getRoleLabel($role) . '.'];
}
/**
* Reset the role override back to the real admin role.
*/
public function resetRole(): array
{
if (isset($_SESSION['admin_role_override'])) {
unset($_SESSION['admin_role_override']);
$this->log('info', "Rol-switch gereset: {$_SESSION['admin_user']}");
return ['success' => true, 'message' => 'Rol teruggezet naar admin.'];
}
return ['success' => false, 'message' => 'Geen rol-override actief.'];
}
/**
* Check if the current user has permission to access a route.
*/
@@ -308,9 +367,13 @@ class AdminAuth
/**
* Change the role of an existing user.
* A user can never change their own role (security guard).
*/
public function changeRole(string $username, string $role): array
{
if ($username === ($_SESSION['admin_user'] ?? '')) {
return ['success' => false, 'message' => 'Je kunt je eigen rol niet wijzigen.'];
}
if (!isset(self::ROLE_PERMISSIONS[$role])) {
return ['success' => false, 'message' => 'Ongeldige rol.'];
}
@@ -7,7 +7,7 @@
var ext = textarea.dataset.ext || 'md';
var form = document.getElementById('editor-form') || textarea.closest('form');
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php', css: 'css', scss: 'css', js: 'javascript', json: 'javascript' };
var editor = CodeMirror.fromTextArea(textarea, {
mode: modeMap[ext] || 'markdown',
@@ -146,8 +146,11 @@
insert(editor, '/**\n * \n */', 7);
break;
case 'media':
var modal = new bootstrap.Modal(document.getElementById('mediaModal'));
if (modal) modal.show();
var modalEl = document.getElementById('mediaModal');
if (modalEl && window.bootstrap) {
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
break;
}
}
+65 -5
View File
@@ -41,7 +41,7 @@
</div>
<ul class="nav flex-column mt-2">
{# Algemene sectie: Dashboard (altijd zichtbaar) + plugin items met section=general #}
{% set general_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'general' and item.route != 'dashboard') %}
{% set general_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'general' and item.route != 'dashboard' and (item.permission is not defined or item.permission == 'dashboard' or has_permission(item.permission)) and (item.required_roles is not defined or user_role == 'admin' or user_role in item.required_roles)) %}
<li class="nav-section">{{ ta.section_general|default('Algemeen') }}</li>
<li class="nav-item">
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
@@ -51,7 +51,7 @@
{% for item in general_plugins %}
<li class="nav-item">
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ plugin_menu_label(item) }}
</a>
</li>
{% endfor %}
@@ -91,7 +91,7 @@
{% endif %}
{# Systeem sectie: core admin items + plugin items met section=system #}
{% set system_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'system') %}
{% set system_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'system' and (item.permission is not defined or item.permission == 'dashboard' or has_permission(item.permission)) and (item.required_roles is not defined or user_role == 'admin' or user_role in item.required_roles)) %}
{% if has_permission('plugins') or has_permission('users') or has_permission('update') or system_plugins is not empty %}
<li class="nav-section">{{ ta.section_system|default('Systeem') }}</li>
{% if has_permission('plugins') %}
@@ -118,7 +118,7 @@
{% for item in system_plugins %}
<li class="nav-item">
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ plugin_menu_label(item) }}
</a>
</li>
{% endfor %}
@@ -135,6 +135,18 @@
</ul>
<ul class="nav flex-column mt-auto mb-5">
{% if user_real_role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
<i class="bi bi-person-bounding-box"></i>
{% if has_role_override %}
{{ ta.role_testing|default('Testen') }}: {{ role_label(user_role) }}
{% else %}
{{ ta.role_switch|default('Rol wisselen') }}
{% endif %}
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> {{ ta.view_website|default('Website bekijken') }}
@@ -148,10 +160,54 @@
</ul>
<div class="admin-user">
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
<br><small>{{ role_label(user_role)|default('') }}</small>
<br><small>{{ role_label(user_role)|default('') }}{% if has_role_override %} <span class="badge bg-warning text-dark">{{ ta.role_testing|default('Test') }}</span>{% endif %}</small>
</div>
</nav>
{# Role switch modal (only rendered for real admins) #}
{% if user_real_role == 'admin' %}
<div class="modal fade" id="roleSwitchModal" tabindex="-1" aria-labelledby="roleSwitchModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/{% if has_role_override %}role-reset{% else %}role-switch{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="modal-header">
<h5 class="modal-title" id="roleSwitchModalLabel">
<i class="bi bi-person-bounding-box"></i>
{% if has_role_override %}{{ ta.role_reset_title|default('Rol terugzetten') }}{% else %}{{ ta.role_switch_title|default('Rol wisselen') }}{% endif %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if has_role_override %}
<p>{{ ta.role_override_active|default('Je test momenteel als') }}: <strong>{{ role_label(user_role) }}</strong></p>
<p class="text-muted small">{{ ta.role_reset_help|default('Klik hieronder om terug te keren naar je admin rol.') }}</p>
{% else %}
<p>{{ ta.role_switch_help|default('Test de admin vanuit een andere rol. Je echte account blijft admin.') }}</p>
<div class="mb-3">
<label for="role_switch_select" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
<select class="form-select" id="role_switch_select" name="new_role">
{% for roleKey, roleLabel in roles|default([]) %}
{% if roleKey != 'admin' %}
<option value="{{ roleKey }}">{{ roleLabel }}</option>
{% endif %}
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary">
{% if has_role_override %}<i class="bi bi-arrow-counterclockwise"></i> {{ ta.role_reset_btn|default('Terug naar admin') }}{% else %}<i class="bi bi-check-lg"></i> {{ ta.role_switch_btn|default('Wissel naar rol') }}{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<main class="admin-main">
{% if message is defined and message %}
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
@@ -163,6 +219,10 @@
{% block content %}{% endblock %}
</main>
{% if needs_editor %}
{% include 'pages/_media-modal.twig' %}
{% endif %}
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
{% if needs_editor %}
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
@@ -0,0 +1,200 @@
{# Reusable media modal, included by admin.twig when needs_editor is true.
The editor toolbar "media" button opens this modal. It fetches the list
of media files from /admin/media-list (JSON) and, on click, inserts a
snippet into the active CodeMirror editor at the cursor.
Scope:
- Default (content editor): fetches /admin/media-list
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
which scans plugins/<name>/assets/ instead of content/.
The snippet format depends on the editor's current mode (data-ext):
- markdown (md): ![alt](url)
- html/php: <img src="url" alt="name">
- other (css, json, js, ...): the raw URL #}
<div class="modal fade" id="mediaModal" tabindex="-1" aria-labelledby="mediaModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mediaModalLabel"><i class="bi bi-images"></i> {{ ta.media_insert|default('Media invoegen') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group input-group-sm mb-3">
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
<input type="search" id="mediaModalFilter" class="form-control" placeholder="{{ ta.media_filter|default('Filter op bestandsnaam...') }}" autocomplete="off" aria-label="{{ ta.media_filter|default('Filter') }}">
</div>
<div id="mediaModalGrid" class="row g-3">
<div class="col-12 text-center text-muted py-4" id="mediaModalLoading">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
</div>
<div class="col-12 text-center text-muted py-4 d-none" id="mediaModalEmpty">
<i class="bi bi-image display-6 d-block mb-2"></i>
{{ mediaEmptyText|default('Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.') }}
</div>
<div class="col-12 text-center text-danger py-4 d-none" id="mediaModalError">
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
<span id="mediaModalErrorText"></span>
</div>
{# Filled by JS #}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
</div>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var loaded = false;
var items = [];
function formatSnippet(url, name, ext, mode) {
if (mode === 'markdown') {
return '![' + name.replace(/[\[\]]/g, '') + '](' + url + ')';
}
if (mode === 'html' || mode === 'htmlmixed' || mode === 'php') {
var imgExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (imgExts.indexOf(ext) !== -1) {
return '<img src="' + url + '" alt="' + name.replace(/"/g, '&quot;') + '">';
}
if (ext === 'pdf') {
return '<a href="' + url + '">' + name + '</a>';
}
if (['mp4', 'webm', 'mov', 'ogg'].indexOf(ext) !== -1) {
return '<video src="' + url + '" controls></video>';
}
if (['mp3', 'wav'].indexOf(ext) !== -1) {
return '<audio src="' + url + '" controls></audio>';
}
return '<a href="' + url + '">' + name + '</a>';
}
// css, scss, js, json, etc.: just insert the URL
return url;
}
function modeForExt(ext) {
var map = { md: 'markdown', html: 'htmlmixed', php: 'php', css: 'css', scss: 'css', js: 'javascript', json: 'javascript' };
return map[ext] || 'markdown';
}
function renderGrid() {
var grid = document.getElementById('mediaModalGrid');
if (!grid) return;
// Clear previous items (keep loading/empty/error placeholders)
Array.prototype.forEach.call(grid.querySelectorAll('[data-media-item]'), function (el) { el.remove(); });
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
var shown = 0;
items.forEach(function (item) {
if (q && item.name.toLowerCase().indexOf(q) === -1) return;
shown++;
var col = document.createElement('div');
col.className = 'col-6 col-md-4 col-lg-3';
col.setAttribute('data-media-item', '1');
var card = document.createElement('div');
card.className = 'card shadow-sm h-100';
card.style.cursor = 'pointer';
card.title = item.name + ' (' + item.size + ')';
if (item.is_image) {
var img = document.createElement('img');
img.src = item.url;
img.className = 'card-img-top';
img.style.objectFit = 'cover';
img.style.height = '120px';
img.loading = 'lazy';
img.alt = item.name;
card.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'card-img-top d-flex align-items-center justify-content-center bg-light';
ph.style.height = '120px';
var icon = document.createElement('i');
icon.className = 'bi bi-file-earmark-play display-6 text-muted';
ph.appendChild(icon);
card.appendChild(ph);
}
var body = document.createElement('div');
body.className = 'card-body p-2';
var label = document.createElement('p');
label.className = 'card-text small text-muted text-truncate mb-0';
label.textContent = item.name;
body.appendChild(label);
card.appendChild(body);
card.addEventListener('click', function () {
var editor = window.codeMirrorEditor;
if (!editor) { return; }
var ext = (document.getElementById('editor-textarea') || {}).dataset;
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
var mode = modeForExt(fileExt);
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
editor.replaceSelection(snippet);
editor.focus();
// Close the modal
var modalEl = document.getElementById('mediaModal');
if (window.bootstrap && modalEl) {
var inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
}
});
col.appendChild(card);
grid.appendChild(col);
});
// Toggle empty placeholder
var empty = document.getElementById('mediaModalEmpty');
var loading = document.getElementById('mediaModalLoading');
if (loading) loading.classList.add('d-none');
if (empty) empty.classList.toggle('d-none', shown > 0);
}
function load() {
if (loaded) { renderGrid(); return; }
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
var url = '/admin/media-list';
if (plugin) { url += '?plugin=' + encodeURIComponent(plugin); }
fetch(url, { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
items = Array.isArray(data) ? data : [];
loaded = true;
renderGrid();
})
.catch(function (err) {
var loading = document.getElementById('mediaModalLoading');
if (loading) loading.classList.add('d-none');
var errEl = document.getElementById('mediaModalError');
var errTxt = document.getElementById('mediaModalErrorText');
if (errEl) errEl.classList.remove('d-none');
if (errTxt) errTxt.textContent = String(err);
});
}
// Load when the modal is first shown
var modalEl = document.getElementById('mediaModal');
if (modalEl) {
modalEl.addEventListener('show.bs.modal', load);
}
// Filter as the user types
var filter = document.getElementById('mediaModalFilter');
if (filter) {
filter.addEventListener('input', renderGrid);
}
})();
</script>
@@ -40,6 +40,22 @@
{% endif %}
{% endif %}
</div>
{% if isEditable and (currentAuthorName or currentAuthorEmail or currentCreated) %}
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-envelope"></i> {{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
</div>
</div>
{% endif %}
{% if isEditable %}
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
@@ -3,18 +3,98 @@
{% block title %}{{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-plug"></i> {{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }}</h2>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-gear"></i> {{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }}</h2>
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
</div>
<form method="post" class="card shadow-sm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card-body">
<textarea name="config" id="config-textarea" class="form-control font-monospace" rows="20">{{ pluginConfig }}</textarea>
<div class="row g-4">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header">
<i class="bi bi-sliders"></i> {{ ta.plugin_settings|default('Instellingen') }}
</div>
<div class="card-body">
{% if settingsSchema is empty %}
<p class="text-muted mb-0">{{ ta.plugin_no_settings|default('Deze plugin heeft geen instelbare configuratie.') }}</p>
{% else %}
<form method="POST" action="/admin/plugins-config?plugin={{ pluginName|url_encode }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for setting in settingsSchema %}
{% set settingLabel = setting.resolved_label|default('') ? setting.resolved_label : (setting.label|default(setting.key)) %}
{% set settingHelp = setting.resolved_help|default('') ? setting.resolved_help : (setting.help|default('')) %}
{% set settingOptions = setting.resolved_options is not null ? setting.resolved_options : (setting.options|default([])) %}
<div class="mb-4">
<label class="form-label fw-bold" for="setting-{{ setting.key }}">{{ settingLabel }}</label>
{% set currentValue = resolvedConfig[setting.key]|default(setting.default) %}
{% if setting.type == 'select' %}
<select class="form-select" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}">
{% for optValue, optLabel in settingOptions %}
<option value="{{ optValue }}" {{ currentValue == optValue ? 'selected' : '' }}>{{ optLabel }}</option>
{% endfor %}
</select>
{% elseif setting.type == 'multi-select' %}
<div class="d-flex flex-wrap gap-2">
{% for optValue, optLabel in settingOptions %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="setting-{{ setting.key }}-{{ optValue }}" name="setting_{{ setting.key }}[]" value="{{ optValue }}" {{ optValue in currentValue ? 'checked' : '' }}>
<label class="form-check-label" for="setting-{{ setting.key }}-{{ optValue }}">{{ optLabel }}</label>
</div>
{% endfor %}
</div>
{% elseif setting.type == 'checkbox' %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" {{ currentValue ? 'checked' : '' }}>
<label class="form-check-label" for="setting-{{ setting.key }}">{{ settingLabel }}</label>
</div>
{% elseif setting.type == 'number' %}
<input type="number" class="form-control" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" value="{{ currentValue }}" min="1">
{% else %}
<input type="text" class="form-control" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" value="{{ currentValue }}">
{% endif %}
{% if settingHelp %}
<small class="form-text text-muted">{{ settingHelp }}</small>
{% endif %}
</div>
{% endfor %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% endif %}
</div>
</div>
</div>
<div class="card-footer bg-white">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> {{ ta.plugin_info|default('Plugin informatie') }}
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">{{ ta.plugin_name|default('Naam') }}</td><td>{{ pluginJson.name|default(pluginName) }}</td></tr>
<tr><td class="text-muted">{{ ta.version|default('Versie') }}</td><td>{{ pluginJson.version|default('-') }}</td></tr>
<tr><td class="text-muted">{{ ta.author|default('Auteur') }}</td><td>{{ pluginJson.author|default('-') }}</td></tr>
<tr><td class="text-muted">{{ ta.type|default('Type') }}</td><td><span class="badge bg-{{ pluginJson.type == 'system' ? 'primary' : 'success' }}">{{ pluginJson.type|default('content') }}</span></td></tr>
</table>
</div>
</div>
{% if pluginJson.description %}
<div class="card shadow-sm">
<div class="card-header">
<i class="bi bi-card-text"></i> {{ ta.description|default('Beschrijving') }}
</div>
<div class="card-body">
<p class="card-text">{{ pluginJson.description }}</p>
</div>
</div>
{% endif %}
</div>
</form>
</div>
{% endblock %}
+214 -16
View File
@@ -2,36 +2,234 @@
{% block title %}{{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{# Recursively render the plugin file tree as a nested, collapsible list.
A folder is expanded by default when the currently selected file lives
somewhere inside it, so the user always sees where their file is. #}
{% macro tree(nodes, pluginName, relFile, editableExts, csrfToken) %}
{% import _self as macros %}
<ul class="plugin-tree">
{% for n in nodes %}
{% set isActive = n.path == relFile %}
{# A folder contains the active file when relFile starts with n.path + '/' #}
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
{# Icon per type #}
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'json' ? 'bi-filetype-json text-secondary' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension in ['css','scss'] ? 'bi-filetype-css text-info' : (n.extension == 'js' ? 'bi-filetype-js text-warning' : (n.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')))))) %}
<li>
{% if n.is_dir %}
{% set collapseId = 'plugin-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi bi-chevron-down tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, pluginName, relFile, editableExts, csrfToken) }}
{% endif %}
</div>
{% else %}
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
<a href="/admin/plugins-file-move?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="/admin/plugins-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
<input type="hidden" name="plugin" value="{{ pluginName }}">
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 ms-1 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
<style>
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.plugin-tree li { padding: 0; position: relative; }
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Move and delete buttons: always visible, right-aligned, normal icon size */
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
@media (max-width: 768px) {
.plugin-editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-pencil"></i> {{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }}</h2>
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }}</h2>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#pluginUploadForm">
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
<i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file|default('Nieuw bestand') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
</div>
</div>
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Upload form (collapsed by default) #}
<div class="collapse mb-3" id="pluginUploadForm">
<div class="card shadow-sm">
<div class="card-body">
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
<textarea name="content" id="editor-textarea" data-ext="php">{{ pluginContent }}</textarea>
<form method="POST" action="/admin/plugins-file-upload" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="plugin" value="{{ pluginName }}">
<input type="hidden" name="dir" value="" id="pluginUploadDir">
<div class="mb-3">
<label for="pluginUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
<input type="file" class="form-control" id="pluginUploadFile" 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,.css,.scss,.js,.json,.html,.md">
<small class="form-text text-muted">{{ ta.plugin_upload_help|default('Bestanden worden geüpload naar assets/ van deze plugin. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.') }}</small>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
</button>
</form>
</div>
</div>
</div>
<div class="plugin-editor-layout">
{# File tree sidebar #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.plugin_files|default('Plugin bestanden') }}</span>
</div>
<div class="card-body p-2" id="pluginFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ ta.plugin_no_files|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, pluginName, relFile, editableExts, csrf_token) }}
{% endif %}
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
{# Editor main panel #}
<div class="plugin-editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> {{ pluginName }}</li>
{% set crumbPath = '' %}
{% set parts = relFile|split('/') %}
{% for part in parts %}
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
{% if loop.last %}
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
{% else %}
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ crumbPath|url_encode }}">{{ part }}</a></li>
{% endif %}
{% endfor %}
</ol>
</nav>
{% endif %}
{% if isEditable %}
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" id="editor-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card shadow-sm">
<div class="card-body">
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
{% if relFile %}
{{ ta.plugin_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
{% else %}
{{ ta.plugin_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
{% endif %}
</div>
</div>
{% endif %}
</div>
</form>
</div>
{# New file modal #}
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="new_file">
<div class="modal-header">
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newFilenameInput" class="form-label">{{ ta.plugin_file_path|default('Bestandspad binnen plugin') }}</label>
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. helper.php of assets/css/extra.css" required autofocus>
<div class="form-text">{{ ta.plugin_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt.') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@@ -0,0 +1,39 @@
{% extends "layouts/admin.twig" %}
{% block title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
</div>
<form method="post" action="/admin/plugins-file-move?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="card shadow-sm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="plugin" value="{{ pluginName }}">
<input type="hidden" name="file" value="{{ relFile }}">
<div class="card-body">
<div class="alert alert-info">
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
{% if itemDir %}<span class="text-muted">({{ pluginName }}/{{ itemDir }})</span>{% else %}<span class="text-muted">({{ pluginName }}/)</span>{% endif %}
{{ ta.move_to|default('naar:') }}
</div>
<div class="mb-3">
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">({{ pluginName }}/)</small></label>
<select class="form-select" id="destination" name="destination" required>
{% for directory in directories %}
<option value="{{ directory }}">{{ directory == '' ? '(plugin root)' : directory }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="card-footer bg-white">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
</button>
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% endblock %}
+10 -10
View File
@@ -38,31 +38,31 @@
</p>
</div>
<div class="card-footer bg-white">
<div class="btn-group w-100" role="group">
<div class="btn-group w-100" role="group" aria-label="{{ ta.plugin_actions|default('Plugin acties') }}">
{% if plugin.protected or plugin.essential %}
<span class="btn btn-sm btn-outline-secondary disabled" title="{{ ta.essential_title|default('Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd') }}">
<i class="bi bi-shield-check"></i> {{ ta.essential|default('Essentieel') }}
<span class="btn btn-sm btn-outline-secondary disabled" title="{{ ta.essential_title|default('Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd') }}" aria-label="{{ ta.essential|default('Essentieel') }}">
<i class="bi bi-shield-check"></i>
</span>
{% else %}
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> {{ ta.edit|default('Bewerken') }}
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit|default('Bewerken') }}" aria-label="{{ ta.edit|default('Bewerken') }}">
<i class="bi bi-pencil"></i>
</a>
{% if plugin.hasConfig %}
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-gear"></i> {{ ta.config|default('Config') }}
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info" title="{{ ta.config|default('Configuratie') }}" aria-label="{{ ta.config|default('Configuratie') }}">
<i class="bi bi-gear"></i>
</a>
{% endif %}
<form method="POST" action="/admin/plugins-toggle" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="plugin" value="{{ plugin.name }}">
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}">
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i> {{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}" title="{{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}" aria-label="{{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}">
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i>
</button>
</form>
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_plugin|default('Weet je zeker dat je deze plugin wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="plugin" value="{{ plugin.name }}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
@@ -47,6 +47,7 @@
</div>
</div>
{% if can_change_password is not defined or can_change_password %}
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
@@ -70,6 +71,16 @@
</form>
</div>
</div>
{% else %}
<div class="card shadow-sm mb-4 border-warning">
<div class="card-header bg-warning text-dark">
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
</div>
<div class="card-body">
<p class="mb-0 text-muted"><i class="bi bi-info-circle"></i> {{ ta.cannot_change_password_role|default('Wachtwoord wijzigen is niet toegestaan in deze rol.') }}</p>
</div>
</div>
{% endif %}
</div>
<div class="col-lg-4">
@@ -84,6 +95,12 @@
{{ target_user.role_label|default(target_user.role) }}
</span>
</p>
{% if is_self %}
<div class="alert alert-info mb-0" role="alert">
<i class="bi bi-info-circle"></i>
{{ ta.cannot_change_own_role|default('Je kunt je eigen rol niet wijzigen.') }}
</div>
{% else %}
<hr>
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -100,6 +117,7 @@
<i class="bi bi-check-lg"></i> {{ ta.change|default('Wijzigen') }}
</button>
</form>
{% endif %}
</div>
</div>
+7 -1
View File
@@ -48,8 +48,10 @@ class CodePressCMS {
// Initialize plugin manager (files already loaded in cms/core/index.php)
$enabledPlugins = $this->config['enabled_plugins'] ?? [];
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins);
$siteDefaultLang = $this->config['language']['default'] ?? 'nl';
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins, $siteDefaultLang);
$api = new CMSAPI($this);
$api->setPluginManager($this->pluginManager);
$this->pluginManager->setAPI($api);
$this->buildMenu();
@@ -1378,6 +1380,10 @@ class CodePressCMS {
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => $this->config['author']['git'] ?? '',
// Per-page author metadata (from frontmatter, if present)
'page_author_name' => htmlspecialchars($page['metadata']['author_name'] ?? ''),
'page_author_email' => htmlspecialchars($page['metadata']['author_email'] ?? ''),
'page_created' => htmlspecialchars($page['metadata']['created'] ?? ''),
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
+17
View File
@@ -241,4 +241,21 @@ class ContentAPI
{
return isset($_GET['search']);
}
/**
* Get the author metadata for the current page.
* Returns author_name, author_email and created from the page frontmatter.
*
* @return array Author metadata with keys: author_name, author_email, created
*/
public function getPageAuthor(): array
{
$page = $this->cms->getPage();
$metadata = $page['metadata'] ?? [];
return [
'author_name' => $metadata['author_name'] ?? '',
'author_email' => $metadata['author_email'] ?? '',
'created' => $metadata['created'] ?? '',
];
}
}
+55
View File
@@ -8,11 +8,24 @@ class AdminPluginAPI implements PluginAPIInterface
{
private array $config;
private string $projectRoot;
private string $adminLanguage;
private ?PluginManager $pluginManager = null;
public function __construct(array $siteConfig, string $projectRoot = '')
{
$this->config = $siteConfig;
$this->projectRoot = $projectRoot !== '' ? $projectRoot : dirname(__DIR__, 3);
$this->adminLanguage = $siteConfig['admin_language']
?? ($siteConfig['language']['default'] ?? 'nl');
}
/**
* Inject the admin PluginManager so plugins can resolve their own
* translations through the same fallback chain.
*/
public function setPluginManager(PluginManager $pm): void
{
$this->pluginManager = $pm;
}
/**
@@ -79,4 +92,46 @@ class AdminPluginAPI implements PluginAPIInterface
}
return ['version' => '0.0.0'];
}
/**
* Get the active admin language code (e.g. 'nl', 'en').
* System plugins should use this to resolve their translations.
*/
public function getAdminLanguage(): string
{
return $this->adminLanguage;
}
/**
* Get the translations for a plugin in the admin context.
*
* Fallback chain (handled by PluginManager):
* requested language -> plugin default_language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the active admin language
* @return array Translations [key => value]
*/
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
{
if ($this->pluginManager === null) {
return [];
}
$lang = $lang ?? $this->adminLanguage;
return $this->pluginManager->getPluginTranslations($pluginName, $lang, 'admin');
}
/**
* Translate a single key for a plugin in the admin context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the active admin language
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string
{
$t = $this->getPluginTranslations($pluginName, $lang);
return $t[$key] ?? $key;
}
}
+61
View File
@@ -3,12 +3,22 @@
class CMSAPI implements PluginAPIInterface
{
private CodePressCMS $cms;
private ?PluginManager $pluginManager = null;
public function __construct(CodePressCMS $cms)
{
$this->cms = $cms;
}
/**
* Inject the front-end PluginManager so content plugins can resolve
* their own translations through the same fallback chain.
*/
public function setPluginManager(PluginManager $pm): void
{
$this->pluginManager = $pm;
}
/**
* Get current page information
*/
@@ -220,4 +230,55 @@ class CMSAPI implements PluginAPIInterface
{
return $this->cms->getAllPageTitles();
}
/**
* Get the author metadata for the current page.
* Returns author_name, author_email and created from the page frontmatter.
*
* @return array Author metadata with keys: author_name, author_email, created
*/
public function getPageAuthor(): array
{
$page = $this->cms->getPage();
$metadata = $page['metadata'] ?? [];
return [
'author_name' => $metadata['author_name'] ?? '',
'author_email' => $metadata['author_email'] ?? '',
'created' => $metadata['created'] ?? '',
];
}
/**
* Get the translations for a plugin in the front-end context.
*
* Content plugins follow the current content language. Fallback chain
* (handled by PluginManager): requested language -> plugin
* default_language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the current content language
* @return array Translations [key => value]
*/
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
{
if ($this->pluginManager === null) {
return [];
}
$lang = $lang ?? $this->cms->currentLanguage;
return $this->pluginManager->getPluginTranslations($pluginName, $lang, 'site');
}
/**
* Translate a single key for a plugin in the front-end context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the current content language
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string
{
$t = $this->getPluginTranslations($pluginName, $lang);
return $t[$key] ?? $key;
}
}
+23
View File
@@ -7,4 +7,27 @@
interface PluginAPIInterface
{
public function getConfig(string $key, $default = null);
/**
* Get the translations for a plugin in the current context.
*
* System plugins resolve against the admin language; content plugins
* against the current content language. The implementation handles the
* fallback to the plugin's default_language.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language (optional)
* @return array Translations [key => value]
*/
public function getPluginTranslations(string $pluginName, ?string $lang = null): array;
/**
* Translate a single key for a plugin in the current context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language (optional)
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string;
}
+131 -2
View File
@@ -8,14 +8,25 @@ class PluginManager
private array $enabledPlugins = [];
private array $actions = [];
private array $filters = [];
private string $siteDefaultLanguage = 'nl';
public function __construct(string $pluginsPath, array $enabledPlugins = [])
public function __construct(string $pluginsPath, array $enabledPlugins = [], string $siteDefaultLanguage = 'nl')
{
$this->pluginsPath = $pluginsPath;
$this->enabledPlugins = $enabledPlugins;
$this->siteDefaultLanguage = $siteDefaultLanguage !== '' ? $siteDefaultLanguage : 'nl';
$this->loadPlugins();
}
/**
* Set the CMS site default language. Used as a fallback when a plugin
* does not declare its own `default_language` in plugin.json.
*/
public function setSiteDefaultLanguage(string $lang): void
{
$this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl';
}
public function setAPI($api): void
{
$this->api = $api;
@@ -124,6 +135,123 @@ class PluginManager
return in_array($pluginName, $this->enabledPlugins, true);
}
/**
* Get the runtime config for a plugin: defaults from plugin.json `settings`
* merged with overrides from the plugin's config.json.
*
* @param string $pluginName Plugin name (directory name)
* @return array Resolved config: [key => value, ...]
*/
public function getPluginConfig(string $pluginName): array
{
$pluginDir = $this->pluginsPath . '/' . $pluginName;
$pluginJsonFile = $pluginDir . '/plugin.json';
$configJsonFile = $pluginDir . '/config.json';
$defaults = [];
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
foreach ($pluginJson['settings'] ?? [] as $setting) {
if (isset($setting['key'])) {
$defaults[$setting['key']] = $setting['default'] ?? null;
}
}
}
$overrides = [];
if (file_exists($configJsonFile)) {
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
}
return array_merge($defaults, $overrides);
}
/**
* Get the default (fallback) language declared by a plugin.
*
* Resolved from (in order):
* 1. plugin.json `default_language`
* 2. CMS site config `language.default`
* 3. 'nl'
*
* @param string $pluginName Plugin directory name
* @return string Language code (e.g. 'nl', 'en')
*/
public function getPluginDefaultLanguage(string $pluginName): string
{
$pluginJsonFile = $this->pluginsPath . '/' . $pluginName . '/plugin.json';
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
if (!empty($pluginJson['default_language'])) {
return (string)$pluginJson['default_language'];
}
}
return $this->siteDefaultLanguage ?? 'nl';
}
/**
* Load the translations for a plugin for the requested language.
*
* Fallback chain: requested language -> plugin default language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string $lang Requested language code
* @param string $context Translation context: 'admin' or 'site' (front-end).
* Defaults to 'admin' for system plugins, 'site' for content plugins.
* @return array Translations [key => value]
*/
public function getPluginTranslations(string $pluginName, string $lang, string $context = 'admin'): array
{
$langDir = $this->pluginsPath . '/' . $pluginName . '/language';
if (!is_dir($langDir)) {
return [];
}
// Try requested language first
$file = $langDir . '/' . $lang . '/' . $context . '.php';
if (file_exists($file)) {
$t = include $file;
if (is_array($t)) {
return $t;
}
}
// Fallback to the plugin's default language
$defaultLang = $this->getPluginDefaultLanguage($pluginName);
if ($defaultLang !== $lang) {
$fallbackFile = $langDir . '/' . $defaultLang . '/' . $context . '.php';
if (file_exists($fallbackFile)) {
$t = include $fallbackFile;
if (is_array($t)) {
return $t;
}
}
}
return [];
}
/**
* Collect translations for every loaded plugin for the requested language.
*
* Returns an associative array keyed by plugin name:
* ['Statistics' => [...], 'Logs' => [...], ...]
*
* @param string $lang Requested language code
* @param string $context 'admin' or 'site'
* @return array Plugin translations keyed by plugin name
*/
public function getAllPluginTranslations(string $lang, string $context = 'admin'): array
{
$all = [];
foreach ($this->plugins as $pluginName => $plugin) {
// System plugins follow the admin language; content plugins follow the content language.
// The caller decides the context; here we just load for every plugin.
$all[$pluginName] = $this->getPluginTranslations($pluginName, $lang, $context);
}
return $all;
}
public function isPluginViewable(object $plugin): bool
{
if (method_exists($plugin, 'getConfig')) {
@@ -236,7 +364,7 @@ class PluginManager
* Find which plugin handles a given admin route.
*
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
* @return array|null ['plugin' => name, 'action' => action] or null
* @return array|null ['plugin' => name, 'action' => action, 'permission' => required permission] or null
*/
public function resolveAdminRoute(string $route): ?array
{
@@ -248,6 +376,7 @@ class PluginManager
return [
'plugin' => $item['plugin'] ?? '',
'action' => $action,
'permission' => $item['permission'] ?? 'plugins',
];
}
}
+173
View File
@@ -0,0 +1,173 @@
# CodePress CMS v2.6.1d — Release Notes
**Release datum:** 2026-08-18
**Codename:** Lyra
**Status:** stable
---
## Samenvatting
Deze release richt zich volledig op **plugin-verbeteringen**: plugin internationalisatie (i18n), plugin uniformiteit, en een volledig vernieuwde plugin-editor met bestandsbrowser, uploaden, verwijderen en verplaatsen van bestanden binnen een plugin.
---
## Nieuwe features
### 1. Plugin internationalisatie (i18n)
Plugins hebben nu hun eigen `language/` map met vertalingen, geïntegreerd met de admin-taal en content-taal.
- **Systeem plugins** (`type: "system"`) volgen de geselecteerde **admin taal** — vertalingen in `language/<lang>/admin.php`
- **Content plugins** (`type: "content"`) volgen de geselecteerde **content taal** — vertalingen in `language/<lang>/site.php`
- **Fallback chain**: geselecteerde taal → plugin `default_language` (uit `plugin.json`) → CMS site default → lege array (sleutel wordt ongewijzigd getoond)
**Core uitbreidingen:**
- `PluginManager`: `getPluginDefaultLanguage()`, `getPluginTranslations()`, `getAllPluginTranslations()` met fallback chain
- `PluginAPIInterface`: `getPluginTranslations()` + `t()` toegevoegd (contract voor beide API's)
- `AdminPluginAPI`: `getAdminLanguage()`, `getPluginTranslations()`, `t()` (systeem plugins)
- `CMSAPI`: `getPluginTranslations()`, `t()` (content plugins)
**Admin integratie:**
- `public/admin.php` laadt alle plugin-vertalingen als Twig global `ta_plugins` + functions `tap()` en `plugin_menu_label()`
- `handlePluginsConfig()` resolveert `label_key`/`help_key`/`option_label_key` via plugin-vertalingen voor instellingen-labels
- Admin sidebar toont vertaalde plugin-menu labels via `plugin_menu_label()`
**Alle 6 plugins bijgewerkt:**
- Dashboard, GeoIPInfo, Logs, Statistics (systeem): `language/nl/admin.php` + `language/en/admin.php`
- HTMLBlock, Navigation (content): `language/nl/site.php` + `language/en/site.php`
- `plugin.json`: `default_language: "nl"` toegevoegd
- Logs en Statistics: instellingen-labels via `label_key`/`help_key` in plaats van hardcoded tekst
- Alle hardcoded Nederlandse strings in `handleAdminRoute()`/`getSidebarContent()` vervangen door vertaalde waarden
### 2. Plugin uniformiteit
Elke plugin heeft nu een uniforme structuur:
```
plugins/<Plugin>/
├── <Plugin>.php # Hoofd plugin class
├── plugin.json # Metadata + instellingen
├── README.md # Plugin documentatie (uniform format)
├── assets/ # CSS/JS/SCSS (.gitkeep als leeg)
│ └── ...
└── language/ # Vertalingen
├── nl/
└── en/
```
- Per plugin een `README.md` met uniform format: plugin type, bestandsstructuur, functies, instellingen, taal support
- `plugins/README.md` herschreven als korte algemene intro met plugin-overzicht tabel
- `guide/nl|en/codepress-developer/plugin-development.md` volledig herschreven (structuur, plugin.json velden incl. `default_language`, settings schema met `label_key`/`help_key`, API methodes incl. `getPluginTranslations`/`t`, admin integratie, taal support sectie)
- `guide/nl|en/codepress-developer/architectuur.md` bijgewerkt met plugin `language/` map
### 3. Plugin editor volledig vernieuwd
De plugin-editor (`/admin/plugins-edit`) toonde alleen het `<Plugin>.php` bestand. Nu is het een volledige bestandsbeheer-omgeving.
#### Bestandsbrowser zijbalk
- Geneste, inklapbare boomstructuur van alle bestanden in de plugin-map (`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`) inclusief `language/` en `assets/` submappen
- Mappen die het actieve bestand bevatten worden automatisch uitgeklapt
- Iconen per bestandstype (PHP, JSON, MD, CSS, JS, HTML, mappen)
- Bootstrap collapse via `<button>` toggles met chevron-rotatie
- `scanPluginFiles()` helper — recursieve boom-opbouw met path-traversal bescherming
- `editor-toolbar.js` modeMap uitgebreid: `css: 'css'`, `scss: 'css'`, `js: 'javascript'`, `json: 'javascript'`
#### Nieuw bestand aanmaken
- "Nieuw bestand" knop + modal met pad-invoer (bijv. `helper.php` of `assets/css/extra.css`)
- Submappen worden automatisch aangemaakt
- Stub-content per extensie (`<?php\n` voor PHP, `{\n}\n` voor JSON)
- Path-traversal bescherming: `../`/`..\\`/`./` stripping + `..`/`.` segment-guard + realpath prefix-check
#### Uploaden
- Upload-knop + inklapbaar upload-formulier dat bestanden opslaat in `plugins/<naam>/assets/`
- Toegestane extensies: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD
- Nieuwe route: `plugins-file-upload`
- Protected plugins (Navigation) geblokkeerd
- Path-traversal bescherming
#### Verwijderen
- Per-bestand prullenbak-icoon in de bestandsboom (altijd zichtbaar, met mouseover-tekst)
- Nieuwe route: `plugins-file-delete`
- Path-traversal bescherming + protected plugins geblokkeerd + dotfiles geweigerd (`.gitkeep` kan niet verwijderd worden)
- Na verwijderen valt de editor terug op het hoofd-bestand
#### Verplaatsen
- Per-bestand verplaats-icoon (pijlen) in de bestandsboom
- Nieuwe route: `plugins-file-move` + `pages/plugins-move-form.twig`
- Dropdown met alle mappen binnen de plugin (plugin root als optie, huidige map uitgesloten)
- `rename()` uitvoering met path-traversal bescherming voor zowel bron als doel
- Protected plugins geblokkeerd
### 4. Media invoegen in editor
De media-knop in de editor-toolbar was al die tijd stuk — het probeerde een niet-bestaand `#mediaModal` te openen.
- Nieuw JSON-endpoint `/admin/media-list`: retourneert media-bestanden (recursief), images eerst
- Nieuwe herbruikbare Twig include `pages/_media-modal.twig`: Bootstrap modal met zoekfilter en raster van media-bestanden
- Laadt media-lijst via `fetch('/admin/media-list')` bij openen
- Bij klik wordt een snippet in de CodeMirror-editor ingevoegd op de cursor:
- Markdown: `![naam](url)`
- HTML/PHP: `<img src="url" alt="naam">` / `<a>` / `<video>` / `<audio>`
- CSS/SCSS/JS/JSON: ruwe URL
- **Plugin-context**: in de plugin-editor scant het endpoint `plugins/<naam>/assets/` i.p.v. content-map (via `?plugin=` parameter); URLs beginnen met `/plugins/<naam>/assets/`
- `admin.twig` includeert het modal wanneer `needs_editor` true is
- `editor-toolbar.js` media-case robuuster gemaakt
### 5. Plugin overzicht knoppen
De knoppen op de plugin-kaarten (`/admin/plugins`) tonen nu alleen iconen met mouseover-tekst (`title` + `aria-label`) in plaats van icoon + tekst, zodat ze netjes naast elkaar passen in de `btn-group w-100`.
---
## Bug fixes
- **Admin taal niet geïntegreerd met plugins** — opgelost via plugin i18n systeem (zie boven)
- **Plugin editor toonde alleen `<Plugin>.php`** — opgelost via bestandsbrowser (zie boven)
- **Media-knop in editor werkte niet** — `#mediaModal` bestond niet; opgelost via nieuwe media-modal include
- **Plugin editor uploaden/verwijderen/verplaatsen ontbrak** — toegevoegd (zie boven)
- **Plugin-kaart knoppen tekst liep niet goed** — teksten verwijderd, alleen iconen met title
---
## Technische details
### Nieuwe routes
- `media-list` — JSON endpoint voor media-bestanden
- `plugins-file-upload` — bestanden uploaden naar plugin assets/
- `plugins-file-delete` — bestand verwijderen uit plugin
- `plugins-file-move` — bestand verplaatsen binnen plugin
### Nieuwe bestanden
- `admin/theme/default/views/pages/_media-modal.twig` — herbruikbare media modal
- `admin/theme/default/views/pages/plugins-move-form.twig` — verplaats-pagina
- `plugins/<Plugin>/language/{nl,en}/{admin,site}.php` — plugin vertalingen (per plugin)
- `plugins/<Plugin>/README.md` — uniforme documentatie (per plugin)
- `plugins/<Plugin>/assets/.gitkeep` — lege assets map placeholder
### Gewijzigde bestanden (core)
- `cms/core/plugin/PluginManager.php` — i18n methoden, site default language
- `cms/core/plugin/PluginAPIInterface.php``getPluginTranslations()`, `t()` contract
- `cms/core/plugin/AdminPluginAPI.php` — admin language, plugin translations
- `cms/core/plugin/CMSAPI.php` — content language plugin translations
- `cms/core/class/CodePressCMS.php` — PluginManager + CMSAPI wiring
- `public/admin.php` — media-list endpoint, plugin-file routes, plugin translations Twig globals, handlePluginsEdit herschreven, handlePluginsConfig label_key support, flash messages
- `admin/theme/default/views/layouts/admin.twig` — media modal include
- `admin/theme/default/views/pages/plugins-edit.twig` — bestandsbrowser, upload, verwijderen, verplaatsen
- `admin/theme/default/views/pages/plugin-config.twig` — vertaalde instellingen-labels
- `admin/theme/default/views/pages/plugins.twig` — icoon-only knoppen met titles
- `admin/theme/default/assets/js/editor-toolbar.js` — modeMap uitgebreid, media-case robuust
- `language/{nl,en,de}/admin.php` — nieuwe vertaalstrings
---
## Tests
- **Pentest**: 30/30 tests geslaagd — 0 vulnerabilities
- **WCAG 2.1 AA accessibility**: 25/25 tests geslaagd — 100% compliance
---
## Bestanden gewijzigd sinds v2.6.1c
40 bestanden gewijzigd, 2559 insertions(+), 369 deletions(-)
+12 -2
View File
@@ -4,7 +4,7 @@
codepress/
├── cms/core/ # Core engine
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
│ ├── plugin/ # PluginManager, CMSAPI, AdminPluginAPI
│ ├── plugin/ # PluginManager, PluginAPIInterface, CMSAPI, AdminPluginAPI
│ ├── config.php # Config loader
│ └── index.php # Bootstrap
├── admin/ # Admin console
@@ -13,8 +13,14 @@ codepress/
│ └── theme/default/views/ # Twig templates
├── themes/ # Themes (default, ...)
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
│ └── <Plugin>/ # Each plugin has:
│ ├── <Plugin>.php # Main class
│ ├── plugin.json # Metadata + settings
│ ├── README.md # Documentation
│ ├── assets/ # CSS/JS/SCSS
│ └── language/ # Plugin translations (nl/, en/, ...; admin.php/site.php)
├── content/ # Content files (.md, .php, .html)
├── language/ # Language files (nl/, en/, de/)
├── language/ # Core CMS language files (nl/, en/, de/; site.php + admin.php)
├── guide/ # Guides (nl/, en/)
├── public/ # Web root
│ ├── index.php # Website entry point
@@ -22,3 +28,7 @@ codepress/
├── config.json # Site configuration
└── version.php # Version info
```
## Plugin i18n
Plugins have their own `language/` directory with translations. System plugins follow the admin language (`language/<lang>/admin.php`), content plugins follow the content language (`language/<lang>/site.php`). Fallback chain: selected language → plugin `default_language` → CMS site default → empty array. See `guide/en/codepress-developer/plugin-development.md` for details.
@@ -2,14 +2,24 @@
## Plugin structure
Each plugin has its own folder under `plugins/`. The folder name must match the main plugin class name.
```
plugins/MyPlugin/
├── MyPlugin.php # Main plugin class (name = folder name)
├── plugin.json # Plugin metadata
├── config.json # Optional configuration
── assets/ # Optional CSS/JS
├── css/
── scss/
├── MyPlugin.php # Main plugin class (name = folder name)
├── plugin.json # Plugin metadata + settings schema
├── README.md # Plugin documentation
── config.json # Optional runtime configuration (overrides defaults)
├── assets/ # Optional CSS/JS/SCSS
── css/
│ └── scss/
└── language/ # Translations
├── nl/
│ ├── admin.php # Admin labels (system plugins)
│ └── site.php # Front-end labels (content plugins)
└── en/
├── admin.php
└── site.php
```
## plugin.json
@@ -22,7 +32,9 @@ plugins/MyPlugin/
"description": "Description",
"type": "content",
"essential": false,
"hasConfig": false
"hasConfig": false,
"default_language": "nl",
"settings": []
}
```
@@ -37,6 +49,54 @@ plugins/MyPlugin/
| `type` | `"system"` or `"content"` | System (blue badge) or content (green badge) |
| `essential` | boolean | Essential plugins cannot be edited/deleted |
| `hasConfig` | boolean | Shows a Config button in admin |
| `default_language` | string | Fallback language for plugin translations (e.g. `nl`) |
| `settings` | array | Settings schema (see below) |
## Settings schema
When `hasConfig: true`, define settings in the `settings` array:
```json
{
"settings": [
{
"key": "max_items",
"type": "number",
"default": 10,
"label_key": "setting_max_items",
"help_key": "setting_max_items_help"
},
{
"key": "required_roles",
"type": "multi-select",
"default": ["admin"],
"options": {
"admin": "Admin",
"content-manager": "Content Manager"
},
"label_key": "setting_required_roles",
"help_key": "setting_required_roles_help",
"option_label_key": "role_options"
}
]
}
```
### Per-setting fields
| Field | Description |
|-------|-------------|
| `key` | Setting key (stored in `config.json`) |
| `type` | `text`, `checkbox`, `number`, `select`, `multi-select` |
| `default` | Default value |
| `label` | Hardcoded label (fallback when no `label_key` or when translation missing) |
| `help` | Hardcoded help text (fallback when no `help_key`) |
| `label_key` | Key in the plugin's `language/<lang>/admin.php` for a translated label |
| `help_key` | Key in the plugin's `language/<lang>/admin.php` for translated help text |
| `option_label_key` | Key pointing to an array of translated option labels for `select`/`multi-select` |
| `options` | Options for `select`/`multi-select` (`{value: label}`) |
The admin loads defaults from `plugin.json` and overrides them with values from `config.json`. The plugin reads the resolved values via `PluginManager::getPluginConfig()` or its own `getPluginConfig()` method.
## Plugin class example
@@ -63,8 +123,11 @@ class MyPlugin
public function getSidebarContent(): string
{
// Fetch plugin translations (content plugin = front-end language)
$t = $this->api ? $this->api->getPluginTranslations('MyPlugin') : [];
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
return '<p>Current page: ' . htmlspecialchars($title) . '</p>';
$label = $t['current_page'] ?? 'Current page';
return '<p>' . htmlspecialchars($label) . ': ' . htmlspecialchars($title) . '</p>';
}
}
```
@@ -76,20 +139,122 @@ The plugin class is automatically loaded by `PluginManager` when the plugin is l
The API is injected via `setAPI()`, not via a static method. In the front-end context this is a `CMSAPI` instance, in the admin context an `AdminPluginAPI` instance. Both implement `PluginAPIInterface`.
```php
// Front-end API (CMSAPI)
// Front-end API (CMSAPI) - for content plugins
$this->api->getCurrentPageTitle();
$this->api->getMenu();
$this->api->getConfig('site_title');
$this->api->getCurrentLanguage();
$this->api->isHomepage();
$this->api->createUrl('about-us');
$this->api->getPluginTranslations('MyPlugin'); // front-end language
$this->api->t('current_page', 'MyPlugin'); // translation helper
// Admin API (AdminPluginAPI)
// Admin API (AdminPluginAPI) - for system plugins
$this->api->getConfig('analytics.enabled');
$this->api->getContentDir();
$this->api->getEnabledPlugins();
$this->api->getAdminLanguage(); // active admin language
$this->api->getPluginTranslations('MyPlugin'); // admin language
$this->api->t('menu_label', 'MyPlugin'); // translation helper
```
## Language support (i18n)
Each plugin can provide translations in `language/<lang>/`. System plugins use `admin.php`, content plugins use `site.php`.
### Language file format
`language/en/admin.php`:
```php
<?php
return [
// Menu
'menu_label' => 'My Plugin',
// Page
'page_title' => 'My Plugin',
'current_page' => 'Current page',
// Settings (label_key/help_key point here)
'setting_max_items' => 'Maximum number of items',
'setting_max_items_help' => 'Number of items shown.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Manager',
],
];
```
### Fallback chain
Plugin translations are resolved in this order:
1. **Selected language**`language/<selected>/admin.php` (or `site.php`)
2. **Plugin default_language**`plugin.json` `default_language` (e.g. `nl`)
3. **CMS site default**`config.language.default`
4. **Empty array** — the key is shown unchanged
### System vs content plugins
- **System plugins** (`type: "system"`) follow the selected **admin language** (`config.admin_language`). Translations live in `language/<lang>/admin.php`.
- **Content plugins** (`type: "content"`) follow the selected **content language** (from the URL or `config.language.default`). Translations live in `language/<lang>/site.php`.
### Fetching translations in a plugin
```php
// In handleAdminRoute() or getSidebarContent():
$t = $this->api->getPluginTranslations('MyPlugin');
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
echo htmlspecialchars($tr('page_title'));
```
Or use the single-key helper:
```php
echo htmlspecialchars($this->api->t('page_title', 'MyPlugin'));
```
### Translating the menu label
In `getAdminMenu()`, add `label_key`. The admin sidebar shows the translation via `plugin_menu_label()`:
```php
public function getAdminMenu(): array
{
return [
[
'plugin' => 'MyPlugin',
'route' => 'my-plugin',
'label' => 'My Plugin', // fallback
'label_key' => 'menu_label', // points to language/<lang>/admin.php
'icon' => 'bi-puzzle',
'section' => 'general', // or 'system'
'permission' => 'my-plugin',
],
];
}
```
### Translating setting labels
In `plugin.json` `settings`, use `label_key`/`help_key`/`option_label_key` instead of hardcoded `label`/`help`:
```json
{
"key": "max_items",
"label_key": "setting_max_items",
"help_key": "setting_max_items_help",
"type": "number",
"default": 10
}
```
The admin's `handlePluginsConfig()` resolves these via the plugin translations; if a translation is missing it falls back to `label`/`help` from `plugin.json`.
## Hooks
Plugins can implement the following methods for automatic hook registration:
@@ -115,25 +280,72 @@ Plugins can add custom admin pages via `getAdminMenu()` and `handleAdminRoute()`
```php
public function getAdminMenu(): array
{
$config = $this->getPluginConfig();
$requiredRoles = $config['required_roles'] ?? ['admin'];
return [
[
'plugin' => 'MyPlugin',
'route' => 'my-plugin',
'label' => 'My Plugin',
'label_key' => 'menu_label',
'icon' => 'bi-puzzle',
'section' => 'general', // or 'system'
'section' => 'general', // or 'system'
'permission' => 'my-plugin',
'required_roles' => $requiredRoles,
],
];
}
public function handleAdminRoute(string $action): ?string
{
return '<h2>My Plugin admin page</h2>';
$t = $this->api ? $this->api->getPluginTranslations('MyPlugin') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
return '<h2>' . htmlspecialchars($tr('page_title')) . '</h2>';
}
```
Only plugins listed in `enabled_plugins` are shown in the admin sidebar.
### Runtime config in a plugin
Plugins can read their runtime config (defaults from `plugin.json` `settings` + overrides from `config.json`):
```php
private function getPluginConfig(): array
{
$pluginDir = dirname(__DIR__);
$pluginJsonFile = $pluginDir . '/plugin.json';
$configJsonFile = $pluginDir . '/config.json';
$defaults = [];
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
foreach ($pluginJson['settings'] ?? [] as $setting) {
if (isset($setting['key'])) {
$defaults[$setting['key']] = $setting['default'] ?? null;
}
}
}
$overrides = [];
if (file_exists($configJsonFile)) {
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
}
return array_merge($defaults, $overrides);
}
```
Or via the shared method on PluginManager:
```php
$config = $this->pluginManager->getPluginConfig('MyPlugin');
```
## Adding CSS
Plugins can provide a CSS URL via `getCssUrl()`:
@@ -144,3 +356,5 @@ public function getCssUrl(): string
return '/plugins/MyPlugin/assets/css/style.css';
}
```
The URL is passed to the front-end template via `plugin_css_urls`.
+12 -2
View File
@@ -4,7 +4,7 @@
codepress/
├── cms/core/ # Core engine
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
│ ├── plugin/ # PluginManager, CMSAPI, AdminPluginAPI
│ ├── plugin/ # PluginManager, PluginAPIInterface, CMSAPI, AdminPluginAPI
│ ├── config.php # Config loader
│ └── index.php # Bootstrap
├── admin/ # Admin console
@@ -13,8 +13,14 @@ codepress/
│ └── theme/default/views/ # Twig templates
├── themes/ # Thema's (default, ...)
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
│ └── <Plugin>/ # Elke plugin heeft:
│ ├── <Plugin>.php # Hoofd class
│ ├── plugin.json # Metadata + instellingen
│ ├── README.md # Documentatie
│ ├── assets/ # CSS/JS/SCSS
│ └── language/ # Plugin vertalingen (nl/, en/, ...; admin.php/site.php)
├── content/ # Content bestanden (.md, .php, .html)
├── language/ # Taalbestanden (nl/, en/, de/)
├── language/ # Core CMS taalbestanden (nl/, en/, de/; site.php + admin.php)
├── guide/ # Handleidingen (nl/, en/)
├── public/ # Web root
│ ├── index.php # Website entry point
@@ -22,3 +28,7 @@ codepress/
├── config.json # Site configuratie
└── version.php # Versie info
```
## Plugin i18n
Plugins hebben hun eigen `language/` map met vertalingen. Systeem plugins volgen de admin taal (`language/<lang>/admin.php`), content plugins volgen de content taal (`language/<lang>/site.php`). Fallback chain: geselecteerde taal → plugin `default_language` → CMS site default → lege array. Zie `guide/nl/codepress-developer/plugin-development.md` voor details.
@@ -2,14 +2,24 @@
## Plugin structuur
Elke plugin heeft zijn eigen map onder `plugins/`. De mapnaam moet overeenkomen met de hoofd plugin class naam.
```
plugins/MijnPlugin/
├── MijnPlugin.php # Hoofd plugin class (naam = mapnaam)
├── plugin.json # Plugin metadata
├── config.json # Optionele configuratie
── assets/ # Optionele CSS/JS
├── css/
── scss/
├── MijnPlugin.php # Hoofd plugin class (naam = mapnaam)
├── plugin.json # Plugin metadata + instellingen-schema
├── README.md # Plugin documentatie
── config.json # Optionele runtime configuratie (overschrijft defaults)
├── assets/ # Optionele CSS/JS/SCSS
── css/
│ └── scss/
└── language/ # Vertalingen
├── nl/
│ ├── admin.php # Admin labels (systeem plugins)
│ └── site.php # Front-end labels (content plugins)
└── en/
├── admin.php
└── site.php
```
## plugin.json
@@ -22,7 +32,9 @@ plugins/MijnPlugin/
"description": "Beschrijving",
"type": "content",
"essential": false,
"hasConfig": false
"hasConfig": false,
"default_language": "nl",
"settings": []
}
```
@@ -36,7 +48,55 @@ plugins/MijnPlugin/
| `description` | string | Korte beschrijving |
| `type` | `"system"` of `"content"` | Systeem (blauwe badge) of content (groene badge) |
| `essential` | boolean | Essentiële plugins kunnen niet worden bewerkt/verwijderd |
| `hasConfig` | boolean | Toont een Config-knop in de admin |
| `hasConfig` | boolean | Toont een Config-knop in admin |
| `default_language` | string | Fallback taal voor plugin-vertalingen (bijv. `nl`) |
| `settings` | array | Instellingen-schema (zie hieronder) |
## Instellingen-schema
Als `hasConfig: true`, definieer je instellingen in het `settings` array:
```json
{
"settings": [
{
"key": "max_items",
"type": "number",
"default": 10,
"label_key": "setting_max_items",
"help_key": "setting_max_items_help"
},
{
"key": "required_roles",
"type": "multi-select",
"default": ["admin"],
"options": {
"admin": "Admin",
"content-manager": "Content Beheerder"
},
"label_key": "setting_required_roles",
"help_key": "setting_required_roles_help",
"option_label_key": "role_options"
}
]
}
```
### Per-instelling velden
| Veld | Beschrijving |
|------|--------------|
| `key` | Instelling-sleutel (opgeslagen in `config.json`) |
| `type` | `text`, `checkbox`, `number`, `select`, `multi-select` |
| `default` | Standaardwaarde |
| `label` | Hardcoded label (fallback als geen `label_key` of als vertaling ontbreekt) |
| `help` | Hardcoded help-tekst (fallback als geen `help_key`) |
| `label_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaald label |
| `help_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaalde help-tekst |
| `option_label_key` | Sleutel naar een array met vertaalde optie-labels voor `select`/`multi-select` |
| `options` | Opties voor `select`/`multi-select` (`{waarde: label}`) |
De admin laadt defaults uit `plugin.json` en overschrijft ze met waarden uit `config.json`. De plugin leest de uiteindelijke waarden via `PluginManager::getPluginConfig()` of een eigen `getPluginConfig()` methode.
## Plugin class voorbeeld
@@ -63,36 +123,141 @@ class MijnPlugin
public function getSidebarContent(): string
{
// Plugin vertalingen ophalen (content plugin = front-end taal)
$t = $this->api ? $this->api->getPluginTranslations('MijnPlugin') : [];
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
return '<p>Huidige pagina: ' . htmlspecialchars($title) . '</p>';
$label = $t['current_page'] ?? 'Huidige pagina';
return '<p>' . htmlspecialchars($label) . ': ' . htmlspecialchars($title) . '</p>';
}
}
```
De plugin class wordt automatisch geladen door `PluginManager` als de plugin in `enabled_plugins` staat in `config.json`.
## CMSAPI gebruiken
## API gebruiken
De API wordt geïnjecteerd via `setAPI()`, niet via een statische methode. In de front-end context is dit een `CMSAPI` instance, in de admin context een `AdminPluginAPI` instance. Beide implementeren `PluginAPIInterface`.
De API wordt geïnjecteerd via `setAPI()`. In de front-end context is dit een `CMSAPI` instance, in de admin context een `AdminPluginAPI` instance. Beide implementeren `PluginAPIInterface`.
```php
// Front-end API (CMSAPI)
// Front-end API (CMSAPI) - voor content plugins
$this->api->getCurrentPageTitle();
$this->api->getMenu();
$this->api->getConfig('site_title');
$this->api->getCurrentLanguage();
$this->api->isHomepage();
$this->api->createUrl('over-ons');
$this->api->getPluginTranslations('MijnPlugin'); // front-end taal
$this->api->t('current_page', 'MijnPlugin'); // vertaalhelper
// Admin API (AdminPluginAPI)
// Admin API (AdminPluginAPI) - voor systeem plugins
$this->api->getConfig('analytics.enabled');
$this->api->getContentDir();
$this->api->getEnabledPlugins();
$this->api->getAdminLanguage(); // actieve admin taal
$this->api->getPluginTranslations('MijnPlugin'); // admin taal
$this->api->t('menu_label', 'MijnPlugin'); // vertaalhelper
```
## Taal support (i18n)
Elke plugin kan vertalingen leveren in `language/<lang>/`. Systeem plugins gebruiken `admin.php`, content plugins gebruiken `site.php`.
### Taalbestand formaat
`language/nl/admin.php`:
```php
<?php
return [
// Menu
'menu_label' => 'Mijn Plugin',
// Pagina
'page_title' => 'Mijn Plugin',
'current_page' => 'Huidige pagina',
// Instellingen (label_key/help_key verwijzen hiernaar)
'setting_max_items' => 'Maximaal aantal items',
'setting_max_items_help' => 'Aantal items dat getoond wordt.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Beheerder',
],
];
```
### Fallback chain
Plugin-vertalingen worden opgelost in deze volgorde:
1. **Geselecteerde taal**`language/<geselecteerd>/admin.php` (of `site.php`)
2. **Plugin default_language**`plugin.json` `default_language` (bijv. `nl`)
3. **CMS site default**`config.language.default`
4. **Lege array** — de sleutel wordt ongewijzigd getoond
### Systeem vs content plugins
- **Systeem plugins** (`type: "system"`) volgen de geselecteerde **admin taal** (`config.admin_language`). Vertalingen staan in `language/<lang>/admin.php`.
- **Content plugins** (`type: "content"`) volgen de geselecteerde **content taal** (uit de URL of `config.language.default`). Vertalingen staan in `language/<lang>/site.php`.
### Vertalingen ophalen in een plugin
```php
// In handleAdminRoute() of getSidebarContent():
$t = $this->api->getPluginTranslations('MijnPlugin');
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
echo htmlspecialchars($tr('page_title'));
```
Of gebruik de enkele-sleutel helper:
```php
echo htmlspecialchars($this->api->t('page_title', 'MijnPlugin'));
```
### Menu label vertalen
In `getAdminMenu()`, voeg `label_key` toe. De admin sidebar toont de vertaling via `plugin_menu_label()`:
```php
public function getAdminMenu(): array
{
return [
[
'plugin' => 'MijnPlugin',
'route' => 'mijn-plugin',
'label' => 'Mijn Plugin', // fallback
'label_key' => 'menu_label', // verwijst naar language/<lang>/admin.php
'icon' => 'bi-puzzle',
'section' => 'general', // of 'system'
'permission' => 'mijn-plugin',
],
];
}
```
### Instellingen-labels vertalen
In `plugin.json` `settings`, gebruik `label_key`/`help_key`/`option_label_key` in plaats van hardcoded `label`/`help`:
```json
{
"key": "max_items",
"label_key": "setting_max_items",
"help_key": "setting_max_items_help",
"type": "number",
"default": 10
}
```
De admin `handlePluginsConfig()` lost deze op via de plugin-vertalingen; als een vertaling ontbreekt valt het terug op `label`/`help` uit `plugin.json`.
## Hooks
Plugins kunnen de volgende methodes implementeren voor automatiche hook-registratie:
Plugins kunnen de volgende methodes implementeren voor automatische hook-registratie:
**Actions** (geen return waarde):
@@ -115,25 +280,72 @@ Plugins kunnen eigen admin-pagina's toevoegen via `getAdminMenu()` en `handleAdm
```php
public function getAdminMenu(): array
{
$config = $this->getPluginConfig();
$requiredRoles = $config['required_roles'] ?? ['admin'];
return [
[
'plugin' => 'MijnPlugin',
'route' => 'mijn-plugin',
'label' => 'Mijn Plugin',
'label_key' => 'menu_label',
'icon' => 'bi-puzzle',
'section' => 'general', // of 'system'
'section' => 'general', // of 'system'
'permission' => 'mijn-plugin',
'required_roles' => $requiredRoles,
],
];
}
public function handleAdminRoute(string $action): ?string
{
return '<h2>Mijn Plugin admin pagina</h2>';
$t = $this->api ? $this->api->getPluginTranslations('MijnPlugin') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
return '<h2>' . htmlspecialchars($tr('page_title')) . '</h2>';
}
```
Alleen plugins die in `enabled_plugins` staan worden in de admin sidebar getoond.
### Runtime config in een plugin
Plugins kunnen hun runtime config ophalen (defaults uit `plugin.json` `settings` + overrides uit `config.json`):
```php
private function getPluginConfig(): array
{
$pluginDir = dirname(__DIR__);
$pluginJsonFile = $pluginDir . '/plugin.json';
$configJsonFile = $pluginDir . '/config.json';
$defaults = [];
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
foreach ($pluginJson['settings'] ?? [] as $setting) {
if (isset($setting['key'])) {
$defaults[$setting['key']] = $setting['default'] ?? null;
}
}
}
$overrides = [];
if (file_exists($configJsonFile)) {
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
}
return array_merge($defaults, $overrides);
}
```
Of via de gedeelde methode op PluginManager:
```php
$config = $this->pluginManager->getPluginConfig('MijnPlugin');
```
## CSS toevoegen
Plugins kunnen een CSS-URL leveren via `getCssUrl()`:
@@ -144,3 +356,5 @@ public function getCssUrl(): string
return '/plugins/MijnPlugin/assets/css/style.css';
}
```
De URL wordt doorgegeven aan de front-end template via `plugin_css_urls`.
+16
View File
@@ -168,6 +168,11 @@ return [
// Media
'media' => 'Medien',
'no_media' => 'Keine Mediendateien gefunden.',
'media_insert' => 'Medien einfügen',
'media_filter' => 'Nach Dateinamen filtern...',
'media_loading' => 'Medien werden geladen...',
'media_empty' => 'Keine Mediendateien in content/ gefunden. Laden Sie zuerst Dateien über Medien im Menü hoch.',
'media_empty_plugin' => 'Keine Mediendateien im Assets-Ordner dieses Plugins gefunden. Platzieren Sie Dateien direkt im Ordner plugins/{plugin}/assets/, um sie hier einfügen zu können.',
// Plugins
'new_plugin' => 'Neues Plugin',
@@ -262,4 +267,15 @@ return [
'password_label' => 'Passwort',
'login_btn' => 'Anmelden',
'back_to_website' => 'Zurück zur Website',
// Plugin editor (plugins-edit)
'plugin_files' => 'Plugin-Dateien',
'plugin_no_files' => 'Keine Dateien gefunden.',
'plugin_new_file' => 'Neue Datei',
'plugin_new_file_title' => 'Neue Datei erstellen',
'plugin_file_path' => 'Dateipfad innerhalb des Plugins',
'plugin_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterverzeichnisse werden automatisch erstellt.',
'plugin_not_editable' => 'Dieser Dateityp kann im Editor nicht bearbeitet werden.',
'plugin_select_file' => 'Wählen Sie eine Datei aus der Seitenleiste zum Bearbeiten.',
'plugin_upload_help' => 'Dateien werden in den Ordner assets/ dieses Plugins hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
];
+39
View File
@@ -168,6 +168,11 @@ return [
// Media
'media' => 'Media',
'no_media' => 'No media files found.',
'media_insert' => 'Insert media',
'media_filter' => 'Filter by filename...',
'media_loading' => 'Loading media...',
'media_empty' => 'No media files found in content/. Upload files first via Media in the menu.',
'media_empty_plugin' => 'No media files found in this plugin assets folder. Place files directly in plugins/{plugin}/assets/ to insert them here.',
// Plugins
'new_plugin' => 'New plugin',
@@ -262,4 +267,38 @@ return [
'password_label' => 'Password',
'login_btn' => 'Log in',
'back_to_website' => 'Back to website',
// Role switch (admin testing feature)
'role_switch' => 'Switch role',
'role_switch_title' => 'Switch role',
'role_switch_help' => 'Test the admin from another role. Your real account stays admin.',
'role_switch_btn' => 'Switch to role',
'role_testing' => 'Testing',
'role_reset_title' => 'Reset role',
'role_reset_btn' => 'Back to admin',
'role_reset_help' => 'Click below to return to your admin role.',
'role_override_active' => 'You are currently testing as',
'cannot_change_own_role' => 'You cannot change your own role.',
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
'created' => 'Created',
// Plugin config
'plugin_settings' => 'Settings',
'plugin_no_settings' => 'This plugin has no configurable settings.',
'plugin_info' => 'Plugin information',
'plugin_name' => 'Name',
'version' => 'Version',
'type' => 'Type',
'description' => 'Description',
// Plugin editor (plugins-edit)
'plugin_files' => 'Plugin files',
'plugin_no_files' => 'No files found.',
'plugin_new_file' => 'New file',
'plugin_new_file_title' => 'Create new file',
'plugin_file_path' => 'File path within plugin',
'plugin_file_path_help' => 'Only letters, digits, dots, underscores, hyphens and slashes. Subdirectories are created automatically.',
'plugin_not_editable' => 'This file type cannot be edited in the editor.',
'plugin_select_file' => 'Select a file from the sidebar to edit.',
'plugin_upload_help' => 'Files are uploaded to assets/ of this plugin. Allowed: images, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
];
+39
View File
@@ -168,6 +168,11 @@ return [
// Media
'media' => 'Media',
'no_media' => 'Geen media bestanden gevonden.',
'media_insert' => 'Media invoegen',
'media_filter' => 'Filter op bestandsnaam...',
'media_loading' => 'Media laden...',
'media_empty' => 'Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.',
'media_empty_plugin' => 'Geen media bestanden gevonden in de assets-map van deze plugin. Plaats bestanden rechtstreeks in de map plugins/{plugin}/assets/ om ze hier te kunnen invoegen.',
// Plugins
'new_plugin' => 'Nieuwe plugin',
@@ -262,4 +267,38 @@ return [
'password_label' => 'Wachtwoord',
'login_btn' => 'Inloggen',
'back_to_website' => 'Terug naar website',
// Role switch (admin testing feature)
'role_switch' => 'Rol wisselen',
'role_switch_title' => 'Rol wisselen',
'role_switch_help' => 'Test de admin vanuit een andere rol. Je echte account blijft admin.',
'role_switch_btn' => 'Wissel naar rol',
'role_testing' => 'Testen',
'role_reset_title' => 'Rol terugzetten',
'role_reset_btn' => 'Terug naar admin',
'role_reset_help' => 'Klik hieronder om terug te keren naar je admin rol.',
'role_override_active' => 'Je test momenteel als',
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
'created' => 'Aangemaakt',
// Plugin config
'plugin_settings' => 'Instellingen',
'plugin_no_settings' => 'Deze plugin heeft geen instelbare configuratie.',
'plugin_info' => 'Plugin informatie',
'plugin_name' => 'Naam',
'version' => 'Versie',
'type' => 'Type',
'description' => 'Beschrijving',
// Plugin editor (plugins-edit)
'plugin_files' => 'Plugin bestanden',
'plugin_no_files' => 'Geen bestanden gevonden.',
'plugin_new_file' => 'Nieuw bestand',
'plugin_new_file_title' => 'Nieuw bestand aanmaken',
'plugin_file_path' => 'Bestandspad binnen plugin',
'plugin_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt.',
'plugin_not_editable' => 'Dit bestandstype kan niet in de editor bewerkt worden.',
'plugin_select_file' => 'Selecteer een bestand uit de zijbalk om te bewerken.',
'plugin_upload_help' => 'Bestanden worden geüpload naar assets/ van deze plugin. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
];
+27 -19
View File
@@ -25,8 +25,10 @@ class Dashboard
'plugin' => 'Dashboard',
'route' => 'dashboard',
'label' => 'Dashboard',
'label_key' => 'menu_label',
'icon' => 'bi-speedometer2',
'section' => 'general',
'permission' => 'dashboard',
],
];
}
@@ -39,6 +41,12 @@ class Dashboard
$enabledPlugins = $this->api ? $this->api->getEnabledPlugins() : [];
$versionInfo = $this->api ? $this->api->getVersionInfo() : ['version' => '0.0.0'];
// Plugin translations (admin context). Falls back to plugin default_language.
$t = $this->api ? $this->api->getPluginTranslations('Dashboard') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
$stats = [
'pages' => $this->countFiles($contentDir, ['md', 'php', 'html']),
'directories' => $this->countDirs($contentDir),
@@ -68,20 +76,20 @@ class Dashboard
ob_start();
?>
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> <?= htmlspecialchars($tr('page_title')) ?></h2>
<div class="row g-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-info-circle"></i> Site informatie</div>
<div class="card-header"><i class="bi bi-info-circle"></i> <?= htmlspecialchars($tr('site_info')) ?></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Site titel</td><td><?= htmlspecialchars($siteTitle) ?></td></tr>
<tr><td class="text-muted">Standaard taal</td><td><?= htmlspecialchars($defaultLang) ?></td></tr>
<tr><td class="text-muted">CodePress versie</td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
<tr><td class="text-muted">PHP versie</td><td><?= htmlspecialchars($stats['php_version']) ?></td></tr>
<tr><td class="text-muted">Besturingssysteem</td><td><?= htmlspecialchars($stats['os']) ?></td></tr>
<tr><td class="text-muted">Config geladen</td><td><?php if ($stats['config_exists']): ?><span class="badge bg-success">Ja</span><?php else: ?><span class="badge bg-danger">Nee</span><?php endif; ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('site_title')) ?></td><td><?= htmlspecialchars($siteTitle) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('default_lang')) ?></td><td><?= htmlspecialchars($defaultLang) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('cms_version')) ?></td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('php_version')) ?></td><td><?= htmlspecialchars($stats['php_version']) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('os')) ?></td><td><?= htmlspecialchars($stats['os']) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('config_loaded')) ?></td><td><?php if ($stats['config_exists']): ?><span class="badge bg-success"><?= htmlspecialchars($tr('yes')) ?></span><?php else: ?><span class="badge bg-danger"><?= htmlspecialchars($tr('no')) ?></span><?php endif; ?></td></tr>
</table>
</div>
</div>
@@ -89,12 +97,12 @@ class Dashboard
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-folder2-open"></i> Content informatie</div>
<div class="card-header"><i class="bi bi-folder2-open"></i> <?= htmlspecialchars($tr('content_info')) ?></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Pagina's</td><td><span class="badge bg-primary"><?= $stats['pages'] ?></span></td></tr>
<tr><td class="text-muted">Mappen</td><td><span class="badge bg-warning text-dark"><?= $stats['directories'] ?></span></td></tr>
<tr><td class="text-muted">Content grootte</td><td><?= htmlspecialchars($stats['content_size']) ?></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('pages')) ?></td><td><span class="badge bg-primary"><?= $stats['pages'] ?></span></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('folders')) ?></td><td><span class="badge bg-warning text-dark"><?= $stats['directories'] ?></span></td></tr>
<tr><td class="text-muted"><?= htmlspecialchars($tr('content_size')) ?></td><td><?= htmlspecialchars($stats['content_size']) ?></td></tr>
</table>
</div>
</div>
@@ -103,8 +111,8 @@ class Dashboard
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-plug"></i> Plugins</span>
<a href="/admin/plugins" class="btn btn-sm btn-outline-secondary">Beheren</a>
<span><i class="bi bi-plug"></i> <?= htmlspecialchars($tr('plugins')) ?></span>
<a href="/admin/plugins" class="btn btn-sm btn-outline-secondary"><?= htmlspecialchars($tr('manage')) ?></a>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
@@ -113,22 +121,22 @@ class Dashboard
<td>
<i class="bi bi-plug-fill"></i> <?= htmlspecialchars($pluginName) ?>
<?php if ($info['type'] === 'system'): ?>
<span class="badge bg-secondary fs-7">systeem</span>
<span class="badge bg-secondary fs-7"><?= htmlspecialchars($tr('plugin_type_system')) ?></span>
<?php else: ?>
<span class="badge bg-info fs-7">content</span>
<span class="badge bg-info fs-7"><?= htmlspecialchars($tr('plugin_type_content')) ?></span>
<?php endif; ?>
</td>
<td>
<?php if ($info['enabled']): ?>
<span class="badge bg-success">Actief</span>
<span class="badge bg-success"><?= htmlspecialchars($tr('active')) ?></span>
<?php else: ?>
<span class="badge bg-secondary">Inactief</span>
<span class="badge bg-secondary"><?= htmlspecialchars($tr('inactive')) ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($pluginOverview)): ?>
<tr><td colspan="2" class="text-muted text-center">Geen plugins gevonden.</td></tr>
<tr><td colspan="2" class="text-muted text-center"><?= htmlspecialchars($tr('no_plugins')) ?></td></tr>
<?php endif; ?>
</table>
</div>
+39
View File
@@ -0,0 +1,39 @@
# Dashboard Plugin
Toont site informatie, content statistieken en een plugin overzicht op het admin dashboard.
## Plugin type
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
## Bestandsstructuur
```
Dashboard/
├── Dashboard.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # Optionele CSS/JS (leeg)
└── language/ # Vertalingen
├── nl/admin.php # Nederlandse admin labels
└── en/admin.php # Engelse admin labels
```
## Functies
- **Site informatie**: Site titel, standaard taal, CodePress/PHP versie, OS, config status
- **Content informatie**: Aantal pagina's, mappen, content grootte
- **Plugin overzicht**: Lijst van alle plugins met type (systeem/content) en actief/inactief status
## Instellingen
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
## Taal support
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('Dashboard')` om labels op te halen. Fallback chain:
1. Geselecteerde admin taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
View File
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
// Menu
'menu_label' => 'Dashboard',
// Page header
'page_title' => 'Dashboard',
// Site information card
'site_info' => 'Site information',
'site_title' => 'Site title',
'default_lang' => 'Default language',
'cms_version' => 'CodePress version',
'php_version' => 'PHP version',
'os' => 'Operating system',
'config_loaded' => 'Config loaded',
'yes' => 'Yes',
'no' => 'No',
// Content information card
'content_info' => 'Content information',
'pages' => 'Pages',
'folders' => 'Folders',
'content_size' => 'Content size',
// Plugins card
'plugins' => 'Plugins',
'manage' => 'Manage',
'plugin_type_system' => 'system',
'plugin_type_content' => 'content',
'active' => 'Active',
'inactive' => 'Inactive',
'no_plugins' => 'No plugins found.',
];
+34
View File
@@ -0,0 +1,34 @@
<?php
return [
// Menu
'menu_label' => 'Dashboard',
// Page header
'page_title' => 'Dashboard',
// Site informatie card
'site_info' => 'Site informatie',
'site_title' => 'Site titel',
'default_lang' => 'Standaard taal',
'cms_version' => 'CodePress versie',
'php_version' => 'PHP versie',
'os' => 'Besturingssysteem',
'config_loaded' => 'Config geladen',
'yes' => 'Ja',
'no' => 'Nee',
// Content informatie card
'content_info' => 'Content informatie',
'pages' => "Pagina's",
'folders' => 'Mappen',
'content_size' => 'Content grootte',
// Plugins card
'plugins' => 'Plugins',
'manage' => 'Beheren',
'plugin_type_system' => 'systeem',
'plugin_type_content' => 'content',
'active' => 'Actief',
'inactive' => 'Inactief',
'no_plugins' => 'Geen plugins gevonden.',
];
+3 -1
View File
@@ -5,5 +5,7 @@
"description": "Toont site informatie, content statistieken en plugin overzicht op het dashboard.",
"type": "system",
"essential": true,
"hasConfig": false
"hasConfig": false,
"default_language": "nl",
"settings": []
}
+17 -6
View File
@@ -2,9 +2,9 @@
class GeoIPInfo
{
private ?CMSAPI $api = null;
private ?PluginAPIInterface $api = null;
public function setAPI(CMSAPI $api): void
public function setAPI(PluginAPIInterface $api): void
{
$this->api = $api;
}
@@ -23,7 +23,13 @@ class GeoIPInfo
public function getAdminMenu(): array
{
return [
['label' => 'GeoIP Info', 'route' => 'geoip-info', 'icon' => 'bi-globe2'],
[
'plugin' => 'GeoIPInfo',
'label' => 'GeoIP Info',
'label_key' => 'menu_label',
'route' => 'geoip-info',
'icon' => 'bi-globe2',
],
];
}
@@ -46,11 +52,16 @@ class GeoIPInfo
$country = GeoIP::getCountryName($countryCode);
$flag = GeoIP::getCountryFlagEmoji($countryCode);
echo '<h2 class="mb-4"><i class="bi bi-globe2"></i> GeoIP Info</h2>';
$t = $this->api ? $this->api->getPluginTranslations('GeoIPInfo') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
echo '<h2 class="mb-4"><i class="bi bi-globe2"></i> ' . htmlspecialchars($tr('page_title')) . '</h2>';
echo '<div class="card shadow-sm"><div class="card-body">';
echo '<table class="table table-sm">';
echo '<tr><td>IP adres</td><td><code>' . htmlspecialchars($ip) . '</code></td></tr>';
echo '<tr><td>Land</td><td>' . $flag . ' ' . htmlspecialchars($country) . '</td></tr>';
echo '<tr><td>' . htmlspecialchars($tr('ip_address')) . '</td><td><code>' . htmlspecialchars($ip) . '</code></td></tr>';
echo '<tr><td>' . htmlspecialchars($tr('country')) . '</td><td>' . $flag . ' ' . htmlspecialchars($country) . '</td></tr>';
echo '</table>';
echo '</div></div>';
}
+38
View File
@@ -0,0 +1,38 @@
# GeoIPInfo Plugin
Systeem plugin die GeoIP-informatie (IP adres en land) toont op een admin-pagina.
## Plugin type
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
## Bestandsstructuur
```
GeoIPInfo/
├── GeoIPInfo.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # Optionele CSS/JS (leeg)
└── language/ # Vertalingen
├── nl/admin.php # Nederlandse admin labels
└── en/admin.php # Engelse admin labels
```
## Functies
- Toont het IP adres van de bezoeker
- Toont het land (met vlag-emoji) gebaseerd op GeoIP lookup
## Instellingen
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
## Taal support
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('GeoIPInfo')` om labels op te halen. Fallback chain:
1. Geselecteerde admin taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
View File
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Menu
'menu_label' => 'GeoIP Info',
// Page
'page_title' => 'GeoIP Info',
'ip_address' => 'IP address',
'country' => 'Country',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Menu
'menu_label' => 'GeoIP Info',
// Page
'page_title' => 'GeoIP Info',
'ip_address' => 'IP adres',
'country' => 'Land',
];
+5 -1
View File
@@ -3,5 +3,9 @@
"version": "1.0.0",
"author": "CodePress",
"description": "Systeem plugin voor GeoIP informatie in admin",
"type": "system"
"type": "system",
"essential": false,
"hasConfig": false,
"default_language": "nl",
"settings": []
}
+14 -8
View File
@@ -20,16 +20,22 @@ class HTMLBlock
public function getSidebarContent(): string
{
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
// Content plugin: translations follow the current content language.
$t = $this->api ? $this->api->getPluginTranslations('HTMLBlock') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : $tr('unknown');
$isHomepage = $this->api ? $this->api->isHomepage() : false;
$currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl';
$currentPath = $_GET['page'] ?? '';
$currentPath = preg_replace('/\.(md|php|html)$/', '', $currentPath);
$content = '
<p class="mb-2"><strong>Huidige pagina:</strong> ' . htmlspecialchars($currentPage) . '</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-2"><strong>' . htmlspecialchars($tr('current_page')) . ':</strong> ' . htmlspecialchars($currentPage) . '</p>
<p class="mb-2"><strong>' . htmlspecialchars($tr('language')) . ':</strong> ' . strtoupper($currentLang) . '</p>
<p class="mb-3"><strong>' . htmlspecialchars($tr('homepage')) . ':</strong> ' . htmlspecialchars($isHomepage ? $tr('yes') : $tr('no')) . '</p>';
// Add page-specific content
if ($this->api) {
@@ -38,9 +44,9 @@ class HTMLBlock
$content .= '
<div class="alert alert-info mb-3">
<small>
<strong>Bestandsinfo:</strong><br>
Aangemaakt: ' . htmlspecialchars($fileInfo['created']) . '<br>
Gewijzigd: ' . htmlspecialchars($fileInfo['modified']) . '
<strong>' . htmlspecialchars($tr('file_info')) . ':</strong><br>
' . htmlspecialchars($tr('created')) . ': ' . htmlspecialchars($fileInfo['created']) . '<br>
' . htmlspecialchars($tr('modified')) . ': ' . htmlspecialchars($fileInfo['modified']) . '
</small>
</div>';
}
@@ -49,7 +55,7 @@ class HTMLBlock
$menu = $this->api->getMenu();
if (!empty($menu)) {
$content .= '
<h6>Quick Navigation</h6>
<h6>' . htmlspecialchars($tr('quick_navigation')) . '</h6>
<ul class="list-unstyled mb-3">';
foreach ($menu as $item) {
+29 -90
View File
@@ -1,100 +1,39 @@
# HTMLBlock Plugin
Deze plugin toont een custom HTML blok in de sidebar met pagina-informatie en navigatie.
Toont een custom HTML-blok in de sidebar van content-pagina's met pagina-informatie en quick navigation.
## Functies
## Plugin type
- **Pagina informatie**: Toont huidige pagina titel en metadata
- **Bestandsinfo**: Aanmaak- en wijzigingsdatums
- **Dynamische navigatie**: Genereert quick links uit het menu
- **Interactive controls**: Verversen en sidebar toggle
- **Responsive**: Werkt op desktop en mobiel
## Installatie
1. Kopieer de `HTMLBlock` map naar `plugins/`
2. De plugin wordt automatisch geladen
## Gebruik
De plugin wordt automatisch in de sidebar geladen en toont:
### Huidige Pagina Info
- Pagina titel
- Huidige taal
- Homepage status
### Bestandsinformatie
- Aanmaakdatum
- Laatste wijziging
- Bestandsgrootte
### Quick Navigation
- Dynamische links uit het CMS menu
- Automatische URL generatie
### Interactive Controls
- **Ververs Content**: Herlaadt de huidige pagina
- **Toggle Sidebar**: Toont/verbergt de sidebar
## Customization
De plugin content kan worden aangepast door de `getSidebarContent()` methode te wijzigen in `HTMLBlock.php`.
### Voorbeeld Custom Content
```php
public function getSidebarContent(): string
{
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
return '
<div class="card">
<div class="card-body">
<h5>Mijn Custom Block</h5>
<p>Huidige pagina: ' . htmlspecialchars($currentPage) . '</p>
</div>
</div>';
}
```
## API Integration
De plugin maakt gebruik van de CMS API voor:
- `getCurrentPageTitle()` - Huidige pagina titel
- `getCurrentLanguage()` - Huidige taal
- `isHomepage()` - Check of homepage
- `getCurrentPageFileInfo()` - Bestandsinformatie
- `getMenu()` - Menu structuur
- `createUrl($page, $lang)` - URL generatie
## Styling
De plugin gebruikt Bootstrap 5 classes:
- `card`, `card-header`, `card-body` voor kaarten
- `btn`, `btn-outline-primary` voor knoppen
- `list-unstyled` voor navigatie
## JavaScript
De plugin bevat JavaScript voor:
- Pagina verversen
- Sidebar toggle functionaliteit
- Dynamische content updates
## Development
De plugin is een goed voorbeeld voor:
- API integratie
- Dynamic content generatie
- User interface components
- Responsive design
**Content** plugin. De plugin-output volgt de geselecteerde **content taal** (via `CMSAPI::getPluginTranslations()` met `site.php`).
## Bestandsstructuur
```
HTMLBlock/
├── HTMLBlock.php # Hoofd plugin bestand
── README.md # Deze documentatie
├── HTMLBlock.php # Hoofd plugin class
── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # Optionele CSS/JS (leeg)
└── language/ # Vertalingen
├── nl/site.php # Nederlandse front-end labels
└── en/site.php # Engelse front-end labels
```
## Functies
- **Pagina informatie**: Toont huidige pagina titel en metadata
- **Bestandsinfo**: Aanmaak- en wijzigingsdatums
- **Quick Navigation**: Genereert quick links uit het CMS menu
## Instellingen
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
## Taal support
De plugin gebruikt `CMSAPI::getPluginTranslations('HTMLBlock')` om labels op te halen. Fallback chain:
1. Geselecteerde content taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
Beschikbare sleutels staan in `language/<lang>/site.php`.
View File
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
// Sidebar card
'title' => 'HTML Block',
'current_page' => 'Current page',
'language' => 'Language',
'homepage' => 'Homepage',
'yes' => 'Yes',
'no' => 'No',
'unknown' => 'Unknown',
'file_info' => 'File info',
'created' => 'Created',
'modified' => 'Modified',
'quick_navigation' => 'Quick Navigation',
];
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
// Sidebar card
'title' => 'HTML Block',
'current_page' => 'Huidige pagina',
'language' => 'Taal',
'homepage' => 'Homepage',
'yes' => 'Ja',
'no' => 'Nee',
'unknown' => 'Onbekend',
'file_info' => 'Bestandsinfo',
'created' => 'Aangemaakt',
'modified' => 'Gewijzigd',
'quick_navigation' => 'Quick Navigation',
];
+3 -1
View File
@@ -5,5 +5,7 @@
"description": "Toont aangepaste HTML-blokken in de sidebar van content-pagina's.",
"type": "content",
"essential": false,
"hasConfig": false
"hasConfig": false,
"default_language": "nl",
"settings": []
}
+59 -9
View File
@@ -20,19 +20,63 @@ class Logs
public function getAdminMenu(): array
{
$config = $this->getPluginConfig();
$requiredRoles = $config['required_roles'] ?? ['bi-manager', 'site-admin', 'admin'];
return [
[
'plugin' => 'Logs',
'route' => 'logs',
'label' => 'Logs',
'label_key' => 'menu_label',
'icon' => 'bi-journal-text',
'section' => 'general',
'permission' => 'logs',
'required_roles' => $requiredRoles,
],
];
}
/**
* Get this plugin's runtime config (defaults + overrides).
*/
private function getPluginConfig(): array
{
$pluginDir = dirname(__DIR__);
$pluginJsonFile = $pluginDir . '/plugin.json';
$configJsonFile = $pluginDir . '/config.json';
$defaults = [];
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
foreach ($pluginJson['settings'] ?? [] as $setting) {
if (isset($setting['key'])) {
$defaults[$setting['key']] = $setting['default'] ?? null;
}
}
}
$overrides = [];
if (file_exists($configJsonFile)) {
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
}
return array_merge($defaults, $overrides);
}
public function handleAdminRoute(string $action): ?string
{
$config = $this->getPluginConfig();
$maxLines = (int)($config['max_lines'] ?? 100);
$showAdminTab = $config['show_admin_tab'] ?? true;
$showRequestsTab = $config['show_requests_tab'] ?? true;
// System plugin: translations resolve against the admin language.
$t = $this->api ? $this->api->getPluginTranslations('Logs') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
$tab = $_GET['tab'] ?? 'admin';
$logDir = dirname(__DIR__, 2) . '/admin/storage/logs';
$logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log';
@@ -40,7 +84,7 @@ class Logs
$logs = [];
if (file_exists($logFile)) {
$lines = file($logFile) ?: [];
$lines = array_slice($lines, -100);
$lines = array_slice($lines, -$maxLines);
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
$logs[] = [
@@ -56,31 +100,37 @@ class Logs
ob_start();
?>
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
<h2 class="mb-4"><i class="bi bi-journal-text"></i> <?= htmlspecialchars($tr('page_title')) ?></h2>
<?php if ($showAdminTab || $showRequestsTab): ?>
<ul class="nav nav-tabs mb-3">
<?php if ($showAdminTab): ?>
<li class="nav-item">
<a class="nav-link <?= $tab === 'admin' ? 'active' : '' ?>" href="/admin/logs?tab=admin">Admin</a>
<a class="nav-link <?= $tab === 'admin' ? 'active' : '' ?>" href="/admin/logs?tab=admin"><?= htmlspecialchars($tr('tab_admin')) ?></a>
</li>
<?php endif; ?>
<?php if ($showRequestsTab): ?>
<li class="nav-item">
<a class="nav-link <?= $tab === 'requests' ? 'active' : '' ?>" href="/admin/logs?tab=requests">Requests</a>
<a class="nav-link <?= $tab === 'requests' ? 'active' : '' ?>" href="/admin/logs?tab=requests"><?= htmlspecialchars($tr('tab_requests')) ?></a>
</li>
<?php endif; ?>
</ul>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Tijd</th>
<th>Level</th>
<th>IP</th>
<th>Bericht</th>
<th><?= htmlspecialchars($tr('col_time')) ?></th>
<th><?= htmlspecialchars($tr('col_level')) ?></th>
<th><?= htmlspecialchars($tr('col_ip')) ?></th>
<th><?= htmlspecialchars($tr('col_message')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr><td colspan="4" class="text-muted text-center py-4">Geen logs gevonden.</td></tr>
<tr><td colspan="4" class="text-muted text-center py-4"><?= htmlspecialchars($tr('no_logs')) ?></td></tr>
<?php else: ?>
<?php foreach ($logs as $log): ?>
<tr>
+49
View File
@@ -0,0 +1,49 @@
# Logs Plugin
Toont admin activiteitlogs en front-end request logs met filter tabs op een admin-pagina.
## Plugin type
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
## Bestandsstructuur
```
Logs/
├── Logs.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # Optionele CSS/JS (leeg)
└── language/ # Vertalingen
├── nl/admin.php # Nederlandse admin labels
└── en/admin.php # Engelse admin labels
```
## Functies
- **Admin tab**: Toont admin activiteitlogs (uit `admin/storage/logs/admin.log`)
- **Requests tab**: Toont front-end request logs (uit `admin/storage/logs/requests.log`)
- Tabbladen kunnen via de instellingen in/uitgeschakeld worden
- Aantal getoonde regels is instelbaar (meest recente eerst)
## Instellingen
Deze plugin heeft instelbare configuratie (`hasConfig: true`). Instellingen worden gedefinieerd in `plugin.json` onder `settings` en overschreven via `config.json`.
| Sleutel | Type | Standaard | Beschrijving |
|---------|------|-----------|--------------|
| `required_roles` | multi-select | `bi-manager, site-admin, admin` | Rollen die deze plugin mogen zien |
| `max_lines` | number | `100` | Aantal logregels dat getoond wordt (meest recente eerst) |
| `show_admin_tab` | checkbox | `true` | Schakel de admin activiteit-log tab in of uit |
| `show_requests_tab` | checkbox | `true` | Schakel de front-end request-log tab in of uit |
De label- en help-teksten voor instellingen worden vertaald via `label_key`/`help_key` (verwijst naar sleutels in `language/<lang>/admin.php`).
## Taal support
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('Logs')` om labels op te halen. Fallback chain:
1. Geselecteerde admin taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
View File
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
// Menu
'menu_label' => 'Logs',
// Page
'page_title' => 'Logs',
'tab_admin' => 'Admin',
'tab_requests' => 'Requests',
'col_time' => 'Time',
'col_level' => 'Level',
'col_ip' => 'IP',
'col_message' => 'Message',
'no_logs' => 'No logs found.',
'analytics_disabled' => 'Analytics is disabled.',
// Settings (plugin.json label_key/help_key)
'setting_required_roles' => 'Roles allowed to view this plugin',
'setting_required_roles_help' => 'Determines which roles can access the logs page in the admin panel. Admin always has access.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Manager',
'bi-manager' => 'BI Manager',
'site-admin' => 'Site Admin',
],
'setting_max_lines' => 'Maximum number of lines',
'setting_max_lines_help' => 'Number of log lines shown (most recent first).',
'setting_show_admin_tab' => 'Show admin log tab',
'setting_show_admin_tab_help' => 'Enable or disable the admin activity log tab.',
'setting_show_requests_tab' => 'Show request log tab',
'setting_show_requests_tab_help' => 'Enable or disable the front-end request log tab.',
];
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
// Menu
'menu_label' => 'Logs',
// Page
'page_title' => 'Logs',
'tab_admin' => 'Admin',
'tab_requests' => 'Requests',
'col_time' => 'Tijd',
'col_level' => 'Level',
'col_ip' => 'IP',
'col_message' => 'Bericht',
'no_logs' => 'Geen logs gevonden.',
'analytics_disabled' => 'Analytics is uitgeschakeld.',
// Settings (plugin.json label_key/help_key)
'setting_required_roles' => 'Rollen die deze plugin mogen zien',
'setting_required_roles_help' => 'Bepaalt welke rollen toegang hebben tot de logs-pagina in het admin-paneel. Admin heeft altijd toegang.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Beheerder',
'bi-manager' => 'BI Beheerder',
'site-admin' => 'Site Admin',
],
'setting_max_lines' => 'Maximaal aantal regels',
'setting_max_lines_help' => 'Aantal logregels dat getoond wordt (meest recente eerst).',
'setting_show_admin_tab' => 'Toon admin-log tab',
'setting_show_admin_tab_help' => 'Schakel de admin activiteit-log tab in of uit.',
'setting_show_requests_tab' => 'Toon request-log tab',
'setting_show_requests_tab_help' => 'Schakel de front-end request-log tab in of uit.',
];
+39 -1
View File
@@ -5,5 +5,43 @@
"description": "Toont admin activiteitlogs en front-end request logs met filter tabs.",
"type": "system",
"essential": false,
"hasConfig": true
"hasConfig": true,
"default_language": "nl",
"settings": [
{
"key": "required_roles",
"label_key": "setting_required_roles",
"help_key": "setting_required_roles_help",
"option_label_key": "role_options",
"type": "multi-select",
"default": ["bi-manager", "site-admin", "admin"],
"options": {
"admin": "Admin",
"content-manager": "Content Beheerder",
"bi-manager": "BI Beheerder",
"site-admin": "Site Admin"
}
},
{
"key": "max_lines",
"label_key": "setting_max_lines",
"help_key": "setting_max_lines_help",
"type": "number",
"default": 100
},
{
"key": "show_admin_tab",
"label_key": "setting_show_admin_tab",
"help_key": "setting_show_admin_tab_help",
"type": "checkbox",
"default": true
},
{
"key": "show_requests_tab",
"label_key": "setting_show_requests_tab",
"help_key": "setting_show_requests_tab_help",
"type": "checkbox",
"default": true
}
]
}
+47
View File
@@ -0,0 +1,47 @@
# Navigation Plugin
Essentiële content plugin die navigatie toont voor handleidingen en content in de sidebar.
## Plugin type
**Content** plugin. De plugin-output volgt de geselecteerde **content taal** (via `CMSAPI::getPluginTranslations()` met `site.php`).
## Bestandsstructuur
```
Navigation/
├── Navigation.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # CSS/SCSS voor navigatie
│ ├── css/navigation.css
│ └── scss/navigation.scss
└── language/ # Vertalingen
├── nl/site.php # Nederlandse front-end labels
└── en/site.php # Engelse front-end labels
```
## Functies
- **Content navigatie**: Bouwt een sidebar navigatie uit de content-mapstructuur
- **Handleiding navigatie**: Bouwt een sidebar navigatie uit de guide-mapstructuur
- Detecteert automatisch of de huidige pagina een handleiding of content pagina is
- Toont mappen (met kinderen) en bestanden (.md, .php, .html)
- Strip taal-prefixen uit bestandsnamen (bijv. `nl.pagina.md``Pagina`)
## Instellingen
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`). Wel levert hij een CSS-URL via `getCssUrl()`.
## Taal support
De plugin-output bestaat voornamelijk uit bestands- en mapnamen (die uit de content zelf komen), dus er zijn weinig te vertalen UI-strings. De `language/<lang>/site.php` bestanden bevatten momenteel alleen `plugin_title` voor uniformiteit met de andere plugins en toekomstige uitbreiding.
De plugin gebruikt `CMSAPI::getPluginTranslations('Navigation')` om labels op te halen. Fallback chain:
1. Geselecteerde content taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
## Beschermd
Deze plugin is **protected**: hardcoded in `public/admin.php` (`getProtectedPlugins()`) en kan niet worden uitgeschakeld, verwijderd of bewerkt via de admin interface.
View File
+7
View File
@@ -0,0 +1,7 @@
<?php
// Navigation is a content plugin. Its sidebar output is built from file and
// directory names, so there are very few translatable UI strings. This file
// exists for uniformity with the other plugins and to allow future labels.
return [
'plugin_title' => 'Navigation',
];
+7
View File
@@ -0,0 +1,7 @@
<?php
// Navigation is a content plugin. Its sidebar output is built from file and
// directory names, so there are very few translatable UI strings. This file
// exists for uniformity with the other plugins and to allow future labels.
return [
'plugin_title' => 'Navigatie',
];
+3 -1
View File
@@ -5,5 +5,7 @@
"description": "Essentiële navigatie plugin voor handleidingen en content",
"type": "content",
"essential": true,
"hasConfig": false
"hasConfig": false,
"default_language": "nl",
"settings": []
}
+76 -79
View File
@@ -1,102 +1,99 @@
# CodePress CMS Plugins
Deze map bevat plugins voor de CodePress CMS. Elke plugin heeft zijn eigen submap met de plugin code.
Deze map bevat alle plugins voor de CodePress CMS. Elke plugin heeft zijn eigen submap.
## Plugin Structuur
## Plugin overzicht
Elke plugin map moet het volgende bevatten:
| Plugin | Type | Essentieel | Beschrijving |
|--------|------|------------|--------------|
| Dashboard | system | ja | Site informatie, content statistieken en plugin overzicht op het dashboard |
| GeoIPInfo | system | nee | GeoIP-informatie (IP adres en land) in admin |
| HTMLBlock | content | nee | Custom HTML-blok in de sidebar van content-pagina's |
| Logs | system | nee | Admin activiteitlogs en front-end request logs met filter tabs |
| Navigation | content | ja | Navigatie voor handleidingen en content (beschermd) |
| Statistics | system | nee | Bezoekersstatistieken: views, unieke bezoekers, landen, top pagina's |
Elke plugin heeft een eigen `README.md` met specifieke documentatie.
## Plugin structuur (uniform)
Elke plugin-map heeft de volgende structuur:
```
PluginName/
├── PluginName.php # Hoofd plugin bestand
├── README.md # Plugin documentatie (optioneel)
├── config.json # Plugin configuratie (optioneel)
── assets/ # CSS, JS, images (optioneel)
├── css/
├── js/
└── images/
├── PluginName.php # Hoofd plugin class (naam = mapnaam)
├── plugin.json # Plugin metadata + instellingen-schema
├── README.md # Plugin documentatie
── config.json # Optionele runtime configuratie (overschrijft defaults)
├── assets/ # Optionele CSS/JS/SCSS (.gitkeep als leeg)
├── css/
└── scss/
└── language/ # Vertalingen
├── nl/
│ ├── admin.php # Admin labels (systeem plugins)
│ └── site.php # Front-end labels (content plugins)
└── en/
├── admin.php
└── site.php
```
## Beschikbare Plugins
## plugin.json velden
### HTMLBlock
Toont een custom HTML blok in de sidebar met pagina-informatie en navigatie.
| Veld | Waarde | Beschrijving |
|------|--------|--------------|
| `name` | string | Weergavenaam in admin |
| `version` | string | Versienummer |
| `author` | string | Auteur |
| `description` | string | Korte beschrijving |
| `type` | `"system"` of `"content"` | Systeem (blauwe badge) of content (groene badge) |
| `essential` | boolean | Essentiële plugins kunnen niet worden bewerkt/verwijderd |
| `hasConfig` | boolean | Toont een Config-knop in admin |
| `default_language` | string | Fallback taal voor plugin-vertalingen (bijv. `nl`) |
| `settings` | array | Instellingen-schema (zie hieronder) |
**Locatie:** `HTMLBlock/HTMLBlock.php`
### Instellingen-schema
**Functies:**
- Toont huidige pagina informatie
- Dynamische navigatie
- Bestandsinformatie
- Interactive controls
Elke instelling in `settings` ondersteunt:
## Plugin Development
| Veld | Beschrijving |
|------|--------------|
| `key` | Instelling-sleutel (opgeslagen in `config.json`) |
| `type` | `text`, `checkbox`, `number`, `select`, `multi-select` |
| `default` | Standaardwaarde |
| `label` | Hardcoded label (fallback als geen `label_key`) |
| `help` | Hardcoded help-tekst (fallback als geen `help_key`) |
| `label_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaald label |
| `help_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaalde help-tekst |
| `option_label_key` | Sleutel naar een array met optie-labels voor `select`/`multi-select` |
| `options` | Opties voor `select`/`multi-select` (`{waarde: label}`) |
### Basis Plugin Class
## Taal support
```php
<?php
- **Systeem plugins** volgen de geselecteerde **admin taal** (uit `config.admin_language`).
- **Content plugins** volgen de geselecteerde **content taal** (uit de URL of `config.language.default`).
class MyPlugin
{
private ?CMSAPI $api = null;
Fallback chain voor plugin-vertalingen:
1. Geselecteerde taal (`language/<lang>/admin.php` of `site.php`)
2. Plugin `default_language` (`plugin.json`)
3. CMS `language.default`
4. Lege array (sleutel wordt ongewijzigd getoond)
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
}
Plugins halen hun vertalingen op via de API:
- Systeem plugins: `$this->api->getPluginTranslations('PluginName')` (AdminPluginAPI)
- Content plugins: `$this->api->getPluginTranslations('PluginName')` (CMSAPI)
public function getSidebarContent(): string
{
return '<div>Mijn plugin content</div>';
}
}
```
## Uitgebreide documentatie
### Beschikbare API Methodes
Zie de handleiding voor uitgebreide plugin development documentatie:
- `getCurrentPage()` - Huidige pagina data
- `getCurrentPageTitle()` - Huidige pagina titel
- `getMenu()` - Menu structuur
- `getConfig($key)` - Configuratie waardes
- `translate($key)` - Vertalingen
- `getCurrentLanguage()` - Huidige taal
- `isHomepage()` - Check of homepage
- `getCurrentPageFileInfo()` - Bestandsinformatie
- `createUrl($page, $lang)` - URL generatie
### Plugin Hooks
Plugins kunnen de volgende methodes implementeren:
- `getSidebarContent()` - Content voor sidebar
- `setAPI(CMSAPI $api)` - API injectie
## Configuratie
Plugins kunnen een `config.json` bestand hebben:
```json
{
"enabled": true,
"settings": {
"option1": "value1",
"option2": "value2"
}
}
```
- `guide/nl/codepress-developer/plugin-development.md`
- `guide/en/codepress-developer/plugin-development.md`
## Installatie
1. Maak een nieuwe map in `plugins/`
2. Plaats de plugin class in `PluginName/PluginName.php`
3. Optioneel: voeg README.md en config.json toe
4. De plugin wordt automatisch geladen door de CMS
## Best Practices
- Gebruik `htmlspecialchars()` voor output
- Implementeer `setAPI()` voor CMS toegang
- Volg PSR-12 coding standards
- Gebruik namespace indien nodig
- Documenteer je plugin met README.md
1. Maak een nieuwe map in `plugins/` (mapnaam = plugin class naam)
2. Voeg de hoofd plugin class toe als `<Naam>.php`
3. Maak een `plugin.json` met metadata en (optioneel) instellingen-schema
4. Voeg een `README.md` toe met plugin documentatie
5. Voeg `language/<lang>/admin.php` of `site.php` toe voor vertalingen
6. Schakel de plugin in via `enabled_plugins` in `config.json` (of via de admin Plugins-pagina)
+51
View File
@@ -0,0 +1,51 @@
# Statistics Plugin
Toont bezoekersstatistieken: totaal aantal views, unieke bezoekers, landen en top pagina's op een admin-pagina.
## Plugin type
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
## Bestandsstructuur
```
Statistics/
├── Statistics.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # Optionele CSS/JS (leeg)
└── language/ # Vertalingen
├── nl/admin.php # Nederlandse admin labels
└── en/admin.php # Engelse admin labels
```
## Functies
- **Totaal aantal views**: Totaal aantal paginaweergaven
- **Unieke bezoekers**: Aantal unieke bezoekers
- **Pagina views**: Totaal aantal pagina views
- **Landen**: Overzicht van bezoekers per land (met vlag-emoji)
- **Top pagina's**: Meest bezochte pagina's
Statistieken worden alleen getoond als Analytics is ingeschakeld in de site configuratie; anders wordt een waarschuwing getoond.
## Instellingen
Deze plugin heeft instelbare configuratie (`hasConfig: true`). Instellingen worden gedefinieerd in `plugin.json` onder `settings` en overschreven via `config.json`.
| Sleutel | Type | Standaard | Beschrijving |
|---------|------|-----------|--------------|
| `required_roles` | multi-select | `bi-manager, site-admin, admin` | Rollen die deze plugin mogen zien |
| `show_countries` | checkbox | `true` | Schakel het landen-overzicht in of uit |
| `show_top_pages` | checkbox | `true` | Schakel het top pagina's overzicht in of uit |
De label- en help-teksten voor instellingen worden vertaald via `label_key`/`help_key` (verwijst naar sleutels in `language/<lang>/admin.php`).
## Taal support
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('Statistics')` om labels op te halen. Fallback chain:
1. Geselecteerde admin taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
+60 -11
View File
@@ -20,24 +20,67 @@ class Statistics
public function getAdminMenu(): array
{
$config = $this->getPluginConfig();
$requiredRoles = $config['required_roles'] ?? ['bi-manager', 'site-admin', 'admin'];
return [
[
'plugin' => 'Statistics',
'route' => 'statistics',
'label' => 'Statistieken',
'label_key' => 'menu_label',
'icon' => 'bi-bar-chart',
'section' => 'general',
'permission' => 'statistics',
'required_roles' => $requiredRoles,
],
];
}
/**
* Get this plugin's runtime config (defaults + overrides).
*/
private function getPluginConfig(): array
{
$pluginDir = dirname(__DIR__);
$pluginJsonFile = $pluginDir . '/plugin.json';
$configJsonFile = $pluginDir . '/config.json';
$defaults = [];
if (file_exists($pluginJsonFile)) {
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
foreach ($pluginJson['settings'] ?? [] as $setting) {
if (isset($setting['key'])) {
$defaults[$setting['key']] = $setting['default'] ?? null;
}
}
}
$overrides = [];
if (file_exists($configJsonFile)) {
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
}
return array_merge($defaults, $overrides);
}
public function handleAdminRoute(string $action): ?string
{
// System plugin: translations resolve against the admin language.
$t = $this->api ? $this->api->getPluginTranslations('Statistics') : [];
$tr = function (string $key) use ($t): string {
return $t[$key] ?? $key;
};
$analytics = $this->getAnalytics();
if ($analytics === null) {
return '<div class="alert alert-warning">Analytics is uitgeschakeld.</div>';
return '<div class="alert alert-warning">' . htmlspecialchars($tr('analytics_disabled')) . '</div>';
}
$config = $this->getPluginConfig();
$showCountries = $config['show_countries'] ?? true;
$showTopPages = $config['show_top_pages'] ?? true;
$stats = $analytics->getStats();
$totals = $stats['totals'] ?? ['views' => 0, 'uniques' => 0, 'pages' => []];
$countries = $stats['countries'] ?? [];
@@ -45,14 +88,14 @@ class Statistics
ob_start();
?>
<h2 class="mb-4"><i class="bi bi-bar-chart"></i> Statistieken</h2>
<h2 class="mb-4"><i class="bi bi-bar-chart"></i> <?= htmlspecialchars($tr('page_title')) ?></h2>
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Totaal aantal views</h6>
<h6 class="text-muted mb-1"><?= htmlspecialchars($tr('total_views')) ?></h6>
<h3 class="mb-0"><?= number_format($totals['views'] ?? 0, 0, ',', '.') ?></h3>
</div>
<i class="bi bi-eye stat-icon text-primary"></i>
@@ -63,7 +106,7 @@ class Statistics
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
<h6 class="text-muted mb-1"><?= htmlspecialchars($tr('unique_visitors')) ?></h6>
<h3 class="mb-0"><?= number_format($totals['uniques'] ?? 0, 0, ',', '.') ?></h3>
</div>
<i class="bi bi-people stat-icon text-success"></i>
@@ -74,7 +117,7 @@ class Statistics
<div class="card stat-card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Pagina views</h6>
<h6 class="text-muted mb-1"><?= htmlspecialchars($tr('page_views')) ?></h6>
<h3 class="mb-0"><?= number_format($totals['page_views'] ?? ($totals['views'] ?? 0), 0, ',', '.') ?></h3>
</div>
<i class="bi bi-file-earmark-text stat-icon text-info"></i>
@@ -83,16 +126,18 @@ class Statistics
</div>
</div>
<?php if ($showCountries || $showTopPages): ?>
<div class="row g-4">
<?php if ($showCountries): ?>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-globe"></i> Landen</div>
<div class="card-header"><i class="bi bi-globe"></i> <?= htmlspecialchars($tr('countries')) ?></div>
<div class="card-body">
<?php if (empty($countries)): ?>
<p class="text-muted mb-0">Geen data</p>
<p class="text-muted mb-0"><?= htmlspecialchars($tr('no_data')) ?></p>
<?php else: ?>
<table class="table table-sm mb-0">
<thead><tr><th>Land</th><th>Views</th></tr></thead>
<thead><tr><th><?= htmlspecialchars($tr('col_country')) ?></th><th><?= htmlspecialchars($tr('col_views')) ?></th></tr></thead>
<tbody>
<?php foreach ($countries as $country => $count): ?>
<tr>
@@ -106,15 +151,17 @@ class Statistics
</div>
</div>
</div>
<?php endif; ?>
<?php if ($showTopPages): ?>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-file-earmark-bar"></i> Top pagina's</div>
<div class="card-header"><i class="bi bi-file-earmark-bar"></i> <?= htmlspecialchars($tr('top_pages')) ?></div>
<div class="card-body">
<?php if (empty($topPages)): ?>
<p class="text-muted mb-0">Geen data</p>
<p class="text-muted mb-0"><?= htmlspecialchars($tr('no_data')) ?></p>
<?php else: ?>
<table class="table table-sm mb-0">
<thead><tr><th>Pagina</th><th>Views</th></tr></thead>
<thead><tr><th><?= htmlspecialchars($tr('col_page')) ?></th><th><?= htmlspecialchars($tr('col_views')) ?></th></tr></thead>
<tbody>
<?php foreach ($topPages as $page => $count): ?>
<tr>
@@ -128,7 +175,9 @@ class Statistics
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
return ob_get_clean();
}
View File
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
// Menu
'menu_label' => 'Statistics',
// Page
'page_title' => 'Statistics',
'total_views' => 'Total views',
'unique_visitors' => 'Unique visitors',
'page_views' => 'Page views',
'countries' => 'Countries',
'top_pages' => 'Top pages',
'no_data' => 'No data',
'col_country' => 'Country',
'col_views' => 'Views',
'col_page' => 'Page',
'analytics_disabled' => 'Analytics is disabled.',
// Settings (plugin.json label_key/help_key)
'setting_required_roles' => 'Roles allowed to view this plugin',
'setting_required_roles_help' => 'Determines which roles can access the statistics page in the admin panel. Admin always has access.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Manager',
'bi-manager' => 'BI Manager',
'site-admin' => 'Site Admin',
],
'setting_show_countries' => 'Show countries overview',
'setting_show_countries_help' => 'Enable or disable the countries overview on the statistics page.',
'setting_show_top_pages' => 'Show top pages',
'setting_show_top_pages_help' => 'Enable or disable the top pages overview.',
];
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
// Menu
'menu_label' => 'Statistieken',
// Page
'page_title' => 'Statistieken',
'total_views' => 'Totaal aantal views',
'unique_visitors' => 'Unieke bezoekers',
'page_views' => 'Pagina views',
'countries' => 'Landen',
'top_pages' => "Top pagina's",
'no_data' => 'Geen data',
'col_country' => 'Land',
'col_views' => 'Views',
'col_page' => 'Pagina',
'analytics_disabled' => 'Analytics is uitgeschakeld.',
// Settings (plugin.json label_key/help_key)
'setting_required_roles' => 'Rollen die deze plugin mogen zien',
'setting_required_roles_help' => 'Bepaalt welke rollen toegang hebben tot de statistieken-pagina in het admin-paneel. Admin heeft altijd toegang.',
'role_options' => [
'admin' => 'Admin',
'content-manager' => 'Content Beheerder',
'bi-manager' => 'BI Beheerder',
'site-admin' => 'Site Admin',
],
'setting_show_countries' => 'Toon landen-overzicht',
'setting_show_countries_help' => 'Schakel het landen-overzicht in of uit op de statistieken-pagina.',
'setting_show_top_pages' => "Toon top pagina's",
'setting_show_top_pages_help' => "Schakel het top pagina's overzicht in of uit.",
];
+32 -1
View File
@@ -5,5 +5,36 @@
"description": "Bezoekersstatistieken: totaal aantal views, unieke bezoekers, landen en top pagina's.",
"type": "system",
"essential": false,
"hasConfig": true
"hasConfig": true,
"default_language": "nl",
"settings": [
{
"key": "required_roles",
"label_key": "setting_required_roles",
"help_key": "setting_required_roles_help",
"option_label_key": "role_options",
"type": "multi-select",
"default": ["bi-manager", "site-admin", "admin"],
"options": {
"admin": "Admin",
"content-manager": "Content Beheerder",
"bi-manager": "BI Beheerder",
"site-admin": "Site Admin"
}
},
{
"key": "show_countries",
"label_key": "setting_show_countries",
"help_key": "setting_show_countries_help",
"type": "checkbox",
"default": true
},
{
"key": "show_top_pages",
"label_key": "setting_show_top_pages",
"help_key": "setting_show_top_pages_help",
"type": "checkbox",
"default": true
}
]
}
+812 -27
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,8 +4,8 @@
*/
return [
'version' => '2.6.1c',
'release_date' => '2026-08-17',
'version' => '2.6.1d',
'release_date' => '2026-08-18',
'codename' => 'Lyra',
'status' => 'stable',