- Content bestanden met dezelfde naam maar ander type (md/php/html) worden correct geserveerd: URL met extensie opent dat bestand, URL zonder extensie valt terug op md > php > html (resolveContentByType helper) - Admin content editor accepteert bestanden met dezelfde naam (ander type); preview-knop linkt per extensie - Frontend navigatie/directory listing/search tonen elk bestandstype apart - getAllPages() array structuur gewijzigd naar list van ['path','title','type'] met type 'md'/'php'/'html'/'folder' - Verberg-prefix logica: _ is geen verberg-prefix meer, alleen . (en -); admin toont wél alle . bestanden/mappen - ContentAPI getPage()/pageExists() respecteren expliciete extensie - Handleiding content-api.md (NL+EN) herschreven - File-tree unificatie: _file-tree.twig + _editor-styles.twig includes - Versie verhoogd naar 2.6.3
314 lines
15 KiB
Twig
314 lines
15 KiB
Twig
{# 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 renders them as a
|
|
collapsible file tree (consistent with the content/plugin/theme editors).
|
|
On click of a file node, a snippet is inserted into the active CodeMirror
|
|
editor at the cursor.
|
|
|
|
Scope:
|
|
- Default (content editor): fetches /admin/media-list
|
|
which scans content/ (URLs are prefixed with /-media/).
|
|
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
|
|
which scans plugins/<name>/assets/ instead of content/.
|
|
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
|
|
which scans themes/<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="mediaModalTree" class="media-tree-wrap">
|
|
<div class="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="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="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>
|
|
{# Tree populated 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>
|
|
|
|
<style>
|
|
/* Media modal tree — self-contained so the modal works on any editor page. */
|
|
.media-tree-wrap { max-height: 60vh; overflow-y: auto; }
|
|
.media-tree { list-style: none; padding-left: 0; margin: 0; }
|
|
.media-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
|
.media-tree li { padding: 0; position: relative; }
|
|
.media-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; cursor: pointer; }
|
|
.media-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
|
.media-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
|
.media-tree .tree-chevron { flex: 0 0 auto; }
|
|
.media-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
|
.media-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.media-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
|
.media-tree .media-thumb { width: 1.1rem; height: 1.1rem; object-fit: cover; flex: 0 0 auto; border-radius: .2rem; }
|
|
</style>
|
|
|
|
<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>';
|
|
}
|
|
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 iconFor(ext, isImage) {
|
|
if (isImage) return 'bi-file-image text-info';
|
|
if (ext === 'pdf') return 'bi-file-pdf text-danger';
|
|
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
|
|
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
|
|
return 'bi-file text-muted';
|
|
}
|
|
|
|
// Build a nested tree from a flat list of {path, name, ...} items.
|
|
function buildTree(flat) {
|
|
var root = { name: '', path: '', children: {}, isDir: true };
|
|
flat.forEach(function (item) {
|
|
var parts = item.path.split('/');
|
|
var node = root;
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var part = parts[i];
|
|
if (part === '') continue;
|
|
var isLast = i === parts.length - 1;
|
|
if (!node.children[part]) {
|
|
node.children[part] = {
|
|
name: part, path: parts.slice(0, i + 1).join('/'),
|
|
children: {}, isDir: !isLast
|
|
};
|
|
}
|
|
if (isLast) {
|
|
node.children[part].item = item;
|
|
node.children[part].isDir = false;
|
|
}
|
|
node = node.children[part];
|
|
}
|
|
});
|
|
// Convert children maps to sorted arrays (dirs first, then alpha)
|
|
function finalize(n) {
|
|
var arr = Object.keys(n.children).map(function (k) { return n.children[k]; });
|
|
arr.sort(function (a, b) {
|
|
if (a.isDir && !b.isDir) return -1;
|
|
if (!a.isDir && b.isDir) return 1;
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
});
|
|
n.childArr = arr;
|
|
arr.forEach(finalize);
|
|
return n;
|
|
}
|
|
finalize(root);
|
|
return root;
|
|
}
|
|
|
|
function matchesFilter(node, q) {
|
|
if (!q) return true;
|
|
if (node.item && node.item.name.toLowerCase().indexOf(q) !== -1) return true;
|
|
if (node.childArr) {
|
|
for (var i = 0; i < node.childArr.length; i++) {
|
|
if (matchesFilter(node.childArr[i], q)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
|
|
var idCounter = 0;
|
|
|
|
function renderNode(node, container, q) {
|
|
var ul = document.createElement('ul');
|
|
ul.className = 'media-tree';
|
|
node.childArr.forEach(function (child) {
|
|
if (!matchesFilter(child, q)) return;
|
|
var li = document.createElement('li');
|
|
if (child.isDir) {
|
|
var collapseId = 'media-tree-' + (++idCounter);
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'tree-link tree-toggle btn-toggle collapsed';
|
|
btn.setAttribute('data-bs-toggle', 'collapse');
|
|
btn.setAttribute('data-bs-target', '#' + collapseId);
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.title = child.path;
|
|
btn.innerHTML = '<i class="bi bi-chevron-right tree-chevron"></i>' +
|
|
'<i class="bi bi-folder-fill text-warning"></i>' +
|
|
'<span class="text-truncate">' + escapeHtml(child.name) + '</span>';
|
|
li.appendChild(btn);
|
|
var wrap = document.createElement('div');
|
|
wrap.id = collapseId;
|
|
wrap.className = 'tree-collapse collapse';
|
|
renderNode(child, wrap, q);
|
|
li.appendChild(wrap);
|
|
} else {
|
|
var item = child.item;
|
|
var a = document.createElement('a');
|
|
a.className = 'tree-link';
|
|
a.title = item.path + ' (' + item.size + ')';
|
|
var thumb = '';
|
|
if (item.is_image) {
|
|
thumb = '<img class="media-thumb" src="' + item.url + '" alt="" loading="lazy">';
|
|
} else {
|
|
thumb = '<i class="bi ' + iconFor(item.extension, item.is_image) + '"></i>';
|
|
}
|
|
a.innerHTML = '<span class="tree-chevron-spacer"></span>' + thumb +
|
|
'<span class="text-truncate">' + escapeHtml(item.name) + '</span>' +
|
|
'<span class="badge bg-secondary badge-ext">' + item.extension.toUpperCase() + '</span>';
|
|
a.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
insertItem(item);
|
|
});
|
|
li.appendChild(a);
|
|
}
|
|
ul.appendChild(li);
|
|
});
|
|
container.appendChild(ul);
|
|
}
|
|
|
|
function insertItem(item) {
|
|
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();
|
|
var modalEl = document.getElementById('mediaModal');
|
|
if (window.bootstrap && modalEl) {
|
|
var inst = bootstrap.Modal.getInstance(modalEl);
|
|
if (inst) inst.hide();
|
|
}
|
|
}
|
|
|
|
function renderTree() {
|
|
var wrap = document.getElementById('mediaModalTree');
|
|
if (!wrap) return;
|
|
// Clear previous tree (keep placeholders)
|
|
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
|
|
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
|
var tree = buildTree(items);
|
|
var shown = tree.childArr.length;
|
|
if (shown > 0) {
|
|
// When filtering, auto-expand all dirs by rendering without collapsed class
|
|
renderNode(tree, wrap, q);
|
|
}
|
|
var loading = document.getElementById('mediaModalLoading');
|
|
var empty = document.getElementById('mediaModalEmpty');
|
|
if (loading) loading.classList.add('d-none');
|
|
if (empty) empty.classList.toggle('d-none', shown > 0);
|
|
// When filtering, expand all folders for visibility
|
|
if (q) {
|
|
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-collapse'), function (el) { el.classList.add('show'); });
|
|
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-toggle.btn-toggle'), function (el) {
|
|
el.classList.remove('collapsed');
|
|
el.setAttribute('aria-expanded', 'true');
|
|
var icon = el.querySelector('.tree-chevron');
|
|
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
|
|
});
|
|
}
|
|
}
|
|
|
|
function load() {
|
|
if (loaded) { renderTree(); return; }
|
|
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
|
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
|
var url = '/admin/media-list';
|
|
if (plugin) { url += '?plugin=' + encodeURIComponent(plugin); }
|
|
else if (theme) { url += '?theme=' + encodeURIComponent(theme); }
|
|
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;
|
|
renderTree();
|
|
})
|
|
.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);
|
|
});
|
|
}
|
|
|
|
var modalEl = document.getElementById('mediaModal');
|
|
if (modalEl) {
|
|
modalEl.addEventListener('show.bs.modal', load);
|
|
}
|
|
var filter = document.getElementById('mediaModalFilter');
|
|
if (filter) {
|
|
filter.addEventListener('input', renderTree);
|
|
}
|
|
|
|
// Chevron rotation: swap icon class between chevron-right and chevron-down
|
|
var mediaTreeWrap = document.getElementById('mediaModalTree');
|
|
if (mediaTreeWrap) {
|
|
mediaTreeWrap.addEventListener('shown.bs.collapse', function (e) {
|
|
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
|
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); } }
|
|
});
|
|
mediaTreeWrap.addEventListener('hidden.bs.collapse', function (e) {
|
|
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
|
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); } }
|
|
});
|
|
}
|
|
})();
|
|
</script> |