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:
@@ -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): 
|
||||
- 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, '"') + '">';
|
||||
}
|
||||
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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user