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 88fbaa5702
commit 6d5ca7cab4
64 changed files with 3503 additions and 366 deletions
@@ -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>