v2.6.3 (Lyra): Content multi-type handling, getAllPages() structuur, . verberg-prefix
- 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
This commit is contained in:
@@ -1,19 +1,22 @@
|
||||
{# 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.
|
||||
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 #}
|
||||
- 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">
|
||||
@@ -26,20 +29,20 @@
|
||||
<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 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="col-12 text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
||||
<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="col-12 text-center text-danger py-4 d-none" id="mediaModalError">
|
||||
<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>
|
||||
{# Filled by JS #}
|
||||
{# Tree populated by JS #}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -49,6 +52,22 @@
|
||||
</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';
|
||||
@@ -76,7 +95,6 @@
|
||||
}
|
||||
return '<a href="' + url + '">' + name + '</a>';
|
||||
}
|
||||
// css, scss, js, json, etc.: just insert the URL
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -85,86 +103,167 @@
|
||||
return map[ext] || 'markdown';
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
var grid = document.getElementById('mediaModalGrid');
|
||||
if (!grid) return;
|
||||
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';
|
||||
}
|
||||
|
||||
// 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();
|
||||
// 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
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
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;
|
||||
}
|
||||
|
||||
// Toggle empty placeholder
|
||||
var empty = document.getElementById('mediaModalEmpty');
|
||||
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) { renderGrid(); return; }
|
||||
if (loaded) { renderTree(); return; }
|
||||
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
||||
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
||||
var url = '/admin/media-list';
|
||||
@@ -178,7 +277,7 @@
|
||||
.then(function (data) {
|
||||
items = Array.isArray(data) ? data : [];
|
||||
loaded = true;
|
||||
renderGrid();
|
||||
renderTree();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var loading = document.getElementById('mediaModalLoading');
|
||||
@@ -190,15 +289,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
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>
|
||||
Reference in New Issue
Block a user