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
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
A lightweight, file-based content management system built with PHP (≥8.0).
|
A lightweight, file-based content management system built with PHP (≥8.0).
|
||||||
|
|
||||||
**Version:** 2.6.1 | **License:** AGPL v3 / Commercial
|
**Version:** 2.6.3 | **License:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
||||||
|
|
||||||
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
|
**Versie:** 2.6.3 | **Licentie:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
## bug
|
||||||
|
- [x] Admin /content
|
||||||
|
- [x] onderwater heet de is bij Admin de content, theme en plugin de pluginFileTree overal de zelfde naam. Dit is niet logies en consistent. Als de zelfde twig gebruikt word hou dan de naamgeving algemeen.
|
||||||
|
- [x] Bij Admin/content "Content bestanden" werkt de > bij het openen van de mappen niet. Wees consistenter en compacter. Hergebuik code en maak het universeler. Deze kan gelijk zijn aan de theme en plugin .
|
||||||
|
- [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree
|
||||||
|
- [x] Bij het editen van de content zie ik Zichtbare plugin's. Hier staan nu ook system plugin's in. Maak hier een pulldown multiselect waar ik alleen de active content plugin's ziet
|
||||||
|
- [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent.
|
||||||
|
- [x] De auteur_name en auteur_email moeten hier weg
|
||||||
|
- [x] Ook wil ik dit zie bij elke aanpassing van een pagina --- created: xx-xx-xx xx:xx:xx edited: xx-xx-xx xx:xx:xx ---
|
||||||
|
- [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin
|
||||||
|
- [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt.
|
||||||
|
- [x] GET http://development.codepress.noorlander.info/content/_images/D_D.jpg → 403 Forbidden — opgelost: /admin/media-list content-scope gebruikt nu /-media/ prefix i.p.v. /content/
|
||||||
|
|
||||||
## Nice to have
|
## Nice to have
|
||||||
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
||||||
- [ ] Fase 1: Config-resolutie
|
- [ ] Fase 1: Config-resolutie
|
||||||
@@ -24,66 +37,6 @@
|
|||||||
- [ ] AGENTS.md + config.json.example bijwerken
|
- [ ] AGENTS.md + config.json.example bijwerken
|
||||||
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
||||||
|
|
||||||
- [x] Thema editor (thema's net zo bewerkbaar maken als plugins)
|
|
||||||
- [x] Fase 1: Themabestanden bewerken
|
|
||||||
- [x] Nieuwe route theme-edit + handleThemeEdit() (spiegel van handlePluginsEdit)
|
|
||||||
- [x] scanThemeFiles() helper (geneste boom van thema-map, zoals scanPluginFiles; skipt css_compiled/)
|
|
||||||
- [x] theme-edit.twig met bestandsbrowser zijbalk + CodeMirror editor
|
|
||||||
- [x] Bewerkbare extensies: .twig, .json, .scss, .css, .js, .html, .md, .php
|
|
||||||
- [x] theme.json editor (validatie op structuur: name, template mappings, config)
|
|
||||||
- [x] Layout .twig bestanden bewerken (base.twig, full_content.twig, left_sidebar.twig, etc.)
|
|
||||||
- [x] Partials bewerken (header, navigation, footer)
|
|
||||||
- [x] Nieuw bestand aanmaken binnen thema-map (zoals plugin-editor)
|
|
||||||
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check)
|
|
||||||
- [x] Fase 2: Media/assets beheer
|
|
||||||
- [x] Uploaden naar assets/ van thema (spiegel van plugins-file-upload)
|
|
||||||
- [x] Bestand verwijderen uit thema (spiegel van plugins-file-delete; theme.json beschermd)
|
|
||||||
- [x] Bestand verplaatsen binnen thema (spiegel van plugins-file-move; theme.json beschermd)
|
|
||||||
- [x] Media invoegen in editor: thema-context scant assets/ map (uitbreiding media-list endpoint met ?theme=)
|
|
||||||
- [x] Fase 3: SCSS compileren
|
|
||||||
- [x] SCSS compileren knop in theme-edit (runtime scssphp compile assets/scss/theme.scss → assets/css_compiled/theme.css)
|
|
||||||
- [x] Compile-status tonen (up-to-date/verouderd badges in theme.twig + theme-edit.twig)
|
|
||||||
- [x] Fase 4: Thema overzicht + activeren
|
|
||||||
- [x] theme.twig: Bewerken-knop per thema (link naar theme-edit)
|
|
||||||
- [x] Activeren per thema (active_theme in config.json via theme-activate route)
|
|
||||||
- [x] Themaverwijdering (alleen niet-actieve, niet-default thema's via theme-delete route)
|
|
||||||
- [x] Theme-new uitbreiden met keuze uit bestaand thema als basis (kopieer structuur, skip css_compiled/)
|
|
||||||
- [x] Fase 5: Uniformiteit + documentatie
|
|
||||||
- [x] Uniforme thema-structuur afdwingen (theme.json, assets/scss, assets/css, assets/js, assets/img, assets/fonts, partials, README.md) via createUniformThemeStructure()
|
|
||||||
- [x] Per thema README.md (gegenereerd bij aanmaken via admin)
|
|
||||||
- [x] guide/nl|en/admin-beheerder/thema-beheer.md bijgewerkt met thema-editor uitleg
|
|
||||||
- [x] theme.json schema documentatie in guide (template mappings, config, header_color, etc.)
|
|
||||||
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, SCSS compileren, activeren, verwijderen), path-traversal tests, default/active theme bescherming
|
|
||||||
|
|
||||||
- [x] Content editor consistent met plugins en thema's (uniforme editor-ervaring)
|
|
||||||
- [x] Fase 1: Bestandsbrowser zijbalk voor content
|
|
||||||
- [x] Nieuwe route content-files / handleContentFiles() (spiegel van plugins-edit/theme-edit) naast de bestaande per-pagina content-edit
|
|
||||||
- [x] scanContentFiles() helper (geneste boom van content_dir, zoals scanPluginFiles/scanThemeFiles; via unified scanEditorFiles(scope))
|
|
||||||
- [x] content-files.twig met bestandsbrowser zijbalk + CodeMirror editor (herbruik de plugin/theme editor layout)
|
|
||||||
- [x] Bewerkbare extensies: .md, .php, .html (consistent met bestaande content-edit)
|
|
||||||
- [x] Nieuw bestand aanmaken binnen content_map (zoals plugin/theme-editor) met extensie-keuze + in_dir prefix
|
|
||||||
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check op content_dir)
|
|
||||||
- [x] Fase 2: Media/assets beheer (uniform met plugins/thema's)
|
|
||||||
- [x] Uploaden naar content/ (spiegel van plugins-file-upload/theme-file-upload; content-file-upload route)
|
|
||||||
- [x] Bestand verwijderen uit content (spiegel van plugins-file-delete/theme-file-delete; content-file-delete route)
|
|
||||||
- [x] Bestand verplaatsen binnen content (spiegel van plugins-file-move/theme-file-move; content-file-move route)
|
|
||||||
- [x] Media invoegen in editor: content-context (media-list zonder ?plugin/?theme = content/, al aanwezig)
|
|
||||||
- [x] Fase 3: Mappen beheer (uniform met plugins/thema's structuur)
|
|
||||||
- [x] Nieuwe map aanmaken (content-dir-create-in route, zelfde UX als plugin/theme-editor nieuw-bestand, met in_dir)
|
|
||||||
- [x] Map hernoemen (content-dir-rename-in route, binnen editor zijbalk)
|
|
||||||
- [x] Map verwijderen (content-dir-delete-in route, binnen editor zijbalk, alleen lege mappen)
|
|
||||||
- [ ] Map verplaatsen (spiegel van plugins-file-move/theme-file-move voor mappen) — optioneel, niet geïmplementeerd (mappen verplaatsen is zeldzaam; hernoemen + bestand-verplaatsen dekken de use-case)
|
|
||||||
- [x] Fase 4: Uniformiteit + consistentie
|
|
||||||
- [ ] Eén herbruikbare editor-macro/template voor plugin/theme/content (voorkom dupliek Twig-macro's) — uitgesteld: de macro's verschillen per scope (plugin heeft protected-badge, theme heeft theme.json-bescherming, content heeft mappen-acties); samenvoegen zou een complexe macro met conditionals opleveren die minder leesbaar is dan drie focussen macro's
|
|
||||||
- [x] Eén herbruikbare scanFiles() helper die plugin/theme/content afhandelt (scanEditorFiles(dir, scope) + scanEditorFilesNode; scanPluginFiles/scanThemeFiles/scanContentFiles zijn dunne wrappers)
|
|
||||||
- [ ] Eén herbruikbare fileUpload/fileDelete/fileMove handler-familie met scope-parameter (plugin/theme/content) i.p.v. drie bijna-identieke handlers per type — uitgesteld: de handlers hebben scope-specifieke logica (plugin: protected-plugins check; theme: theme.json-bescherming; content: geen specifieke bescherming) die generalisatie met een configuratie-laag vereist; de huidige per-scope handlers zijn duidelijker dan één handler met veel conditionals
|
|
||||||
- [ ] Per content README.md of index.md (zoals plugins/thema's) — optioneel, alleen voor mappen; niet geïmplementeerd
|
|
||||||
- [x] guide/nl|en/admin-beheerder/content-beheer.md bijwerken met content-editor uitleg (zoals plugin-development.md en thema-beheer.md)
|
|
||||||
- [x] Fase 5: Backup/git integratie in editor
|
|
||||||
- [x] ContentBackup (ZIP/git) acties toegankelijk vanuit de content-editor zijbalk (zoals SCSS-compile knop in theme-edit)
|
|
||||||
- [x] git commit/status tonen in de editor wanneer content/ een git repo is (branch, dirty/clean badge, laatste commit)
|
|
||||||
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, map aanmaken/hernoemen/verwijderen), path-traversal tests (delete/new_file/upload alle geblokkeerd), pentest, WCAG
|
|
||||||
|
|
||||||
## Voltooid ✅
|
## Voltooid ✅
|
||||||
|
|
||||||
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
||||||
@@ -100,6 +53,18 @@
|
|||||||
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
|
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
|
||||||
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
|
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
|
||||||
|
|
||||||
|
## v2.6.3 (2026-08-20) ✅
|
||||||
|
- [x] Content bestanden met dezelfde naam maar ander type (md/php/html) worden door de frontend correct geserveerd: URL met extensie opent dat specifieke bestand, URL zonder extensie valt terug op md > php > html prioriteit (resolveContentByType helper)
|
||||||
|
- [x] Admin content editor accepteert bestanden met dezelfde naam (ander type); "bestaat al" check is per extensie, melding verduidelijkt naar "Bestand met dit type bestaat al."
|
||||||
|
- [x] Admin content editor preview-knop linkt per extensie (test.php → /nl/test.php) zodat elk type individueel te bekijken blijft
|
||||||
|
- [x] Frontend navigatie, directory listing en search tonen elk bestandstype apart (extensie behouden in URL), geen samenvoeging meer
|
||||||
|
- [x] getAllPages() array structuur gewijzigd naar list van ['path' => ..., 'title' => ..., 'type' => ...] met type 'md'/'php'/'html'/'folder'; mappen krijgen eigen entry, bestanden met dezelfde naam botsen niet meer
|
||||||
|
- [x] Verberg-prefix logica gewijzigd: `_` is geen verberg-prefix meer, alleen `.` (en `-`) verbergen aan de frontend; admin toont wél alle `.` bestanden/mappen (.bak, .map) — scanEditorFilesNode content-scope toont dotfiles behalve .git/.gitkeep
|
||||||
|
- [x] ContentAPI getPage()/pageExists() respecteren expliciete extensie in $path
|
||||||
|
- [x] autoLinkPageTitles() aangepast aan nieuwe array structuur, slaat mappen over
|
||||||
|
- [x] Handleiding content-api.md (NL+EN) herschreven: echo/return mechanisme, beschikbare variabelen, nieuwe getAllPages() structuur met voorbeelden
|
||||||
|
- [x] Versie verhoogd naar 2.6.3
|
||||||
|
|
||||||
## v2.6.2 (2026-08-19) ✅
|
## v2.6.2 (2026-08-19) ✅
|
||||||
- [x] Pentest controles uitgevoerd (30/30)
|
- [x] Pentest controles uitgevoerd (30/30)
|
||||||
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #f5f6fa; min-height: 100vh; }
|
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||||
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
|
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; overflow-y: auto; }
|
||||||
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
|
|
||||||
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
||||||
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
||||||
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
||||||
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
.admin-sidebar { width: 100%; height: auto; position: relative; }
|
||||||
.admin-main { margin-left: 0; }
|
.admin-main { margin-left: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="admin-sidebar d-flex flex-column">
|
<nav class="admin-sidebar" id="adminSidebar">
|
||||||
<div class="admin-brand">
|
<div class="admin-brand">
|
||||||
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +133,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="nav flex-column mt-auto mb-5">
|
<ul class="nav flex-column mt-3 mb-4">
|
||||||
{% if user_real_role == 'admin' %}
|
{% if user_real_role == 'admin' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
|
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{# Shared editor layout + file-tree styles for the content, plugin and theme editors.
|
||||||
|
Included via {% include 'pages/_editor-styles.twig' %} inside the extra_css block. #}
|
||||||
|
<style>
|
||||||
|
.editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||||
|
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
|
||||||
|
.editor-main { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.editor-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||||
|
.editor-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||||
|
.editor-tree li { padding: 0; position: relative; }
|
||||||
|
.editor-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; }
|
||||||
|
.editor-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||||
|
.editor-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||||
|
.editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); font-weight: 600; }
|
||||||
|
.editor-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||||
|
.editor-tree .tree-chevron { flex: 0 0 auto; }
|
||||||
|
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||||
|
.editor-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||||
|
.editor-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.editor-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
|
||||||
|
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
||||||
|
.editor-tree .tree-delete-btn:hover { color: #b02a37; }
|
||||||
|
.editor-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; }
|
||||||
|
.editor-tree .tree-move-btn:hover { color: #0d6efd; }
|
||||||
|
.editor-tree li > .tree-link { padding-right: 3.5rem; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||||
|
/* Directory action buttons (new file/dir, rename, delete) — content editor only */
|
||||||
|
.tree-dir-row { position: relative; }
|
||||||
|
.tree-dir-row .tree-toggle { padding-right: 7rem; }
|
||||||
|
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
|
||||||
|
.tree-dir-actions .btn-link { color: #6c757d; }
|
||||||
|
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
|
||||||
|
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
|
||||||
|
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-layout { flex-direction: column; }
|
||||||
|
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 260px; }
|
||||||
|
}
|
||||||
|
/* Plugin checkbox group (content editor) */
|
||||||
|
.plugin-checkbox-group { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.plugin-checkbox-group .form-check { margin-bottom: 0; }
|
||||||
|
.plugin-checkbox-group .form-check-label { font-size: .875rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
{# Shared file-tree partial used by the content, plugin and theme editors.
|
||||||
|
Renders a nested, collapsible file tree from a list of nodes produced by
|
||||||
|
scanEditorFiles()/scanContentFiles()/scanPluginFiles()/scanThemeFiles().
|
||||||
|
|
||||||
|
Required variables (passed via include with ... context):
|
||||||
|
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
|
||||||
|
- relFile: currently selected relative file path (for active highlight + auto-expand)
|
||||||
|
- editableExts: array of editable extensions (shown as .EXT badge)
|
||||||
|
- csrf_token: CSRF token for delete forms
|
||||||
|
- treeIdPrefix: prefix for collapse element ids (e.g. 'content-tree', 'plugin-tree', 'theme-tree')
|
||||||
|
- treeRouteBase: admin route base used to build file links, e.g. '/admin/content'
|
||||||
|
- treeRouteParams: extra query string for file links, e.g. 'plugin=MyPlugin&' or 'theme=default&' or '' (content)
|
||||||
|
- treeDeleteRoute: admin route for file delete, e.g. '/admin/content-file-delete'
|
||||||
|
- treeDeleteName: optional hidden field name for delete form (e.g. 'plugin' or 'theme'); emits no field when empty
|
||||||
|
- treeDeleteValue: optional hidden field value for treeDeleteName (e.g. the plugin/theme name)
|
||||||
|
- treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move'
|
||||||
|
- treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or ''
|
||||||
|
- treeDirActions: boolean; show per-folder new-file/new-dir/rename/delete buttons (content editor only)
|
||||||
|
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
|
||||||
|
|
||||||
|
Icon maps are chosen automatically from the node extension. #}
|
||||||
|
{% macro tree(nodes, opts) %}
|
||||||
|
{% import _self as macros %}
|
||||||
|
<ul class="editor-tree">
|
||||||
|
{% for n in nodes %}
|
||||||
|
{% set isActive = n.path == opts.relFile %}
|
||||||
|
{% set containsActive = n.is_dir and opts.relFile starts with (n.path ~ '/') %}
|
||||||
|
{% if n.is_dir %}
|
||||||
|
{% set icon = 'bi-folder-fill text-warning' %}
|
||||||
|
{% elseif n.extension == 'md' %}
|
||||||
|
{% set icon = 'bi-file-text text-primary' %}
|
||||||
|
{% elseif n.extension == 'php' %}
|
||||||
|
{% set icon = 'bi-file-code text-success' %}
|
||||||
|
{% elseif n.extension == 'twig' %}
|
||||||
|
{% set icon = 'bi-filetype-tfn text-info' %}
|
||||||
|
{% elseif n.extension == 'json' %}
|
||||||
|
{% set icon = 'bi-filetype-json text-secondary' %}
|
||||||
|
{% elseif n.extension in ['css','scss'] %}
|
||||||
|
{% set icon = 'bi-filetype-css text-info' %}
|
||||||
|
{% elseif n.extension == 'js' %}
|
||||||
|
{% set icon = 'bi-filetype-js text-warning' %}
|
||||||
|
{% elseif n.extension == 'html' %}
|
||||||
|
{% set icon = 'bi-file-earmark text-info' %}
|
||||||
|
{% elseif n.extension in ['jpg','jpeg','png','gif','webp','svg'] %}
|
||||||
|
{% set icon = 'bi-file-image text-info' %}
|
||||||
|
{% elseif n.extension == 'pdf' %}
|
||||||
|
{% set icon = 'bi-file-pdf text-danger' %}
|
||||||
|
{% else %}
|
||||||
|
{% set icon = 'bi-file text-muted' %}
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
{% if n.is_dir %}
|
||||||
|
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||||
|
<div class="tree-dir-row">
|
||||||
|
<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 {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
|
||||||
|
<i class="bi {{ icon }}"></i>
|
||||||
|
<span class="text-truncate">{{ n.name }}</span>
|
||||||
|
</button>
|
||||||
|
{% if opts.treeDirActions %}
|
||||||
|
<span class="tree-dir-actions">
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
|
||||||
|
<i class="bi bi-folder-plus"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ n.path }}">
|
||||||
|
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="{{ collapseId }}"
|
||||||
|
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
||||||
|
{% if n.children is not empty %}
|
||||||
|
{{ macros.tree(n.children, opts) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ opts.treeRouteBase }}?{{ opts.treeRouteParams }}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 opts.editableExts %}
|
||||||
|
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
|
||||||
|
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
|
||||||
|
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="{{ opts.treeDeleteRoute }}" 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="{{ opts.csrfToken }}">
|
||||||
|
{% if opts.treeDeleteName %}
|
||||||
|
<input type="hidden" name="{{ opts.treeDeleteName }}" value="{{ opts.treeDeleteValue }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="file" value="{{ n.path }}">
|
||||||
|
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endmacro %}
|
||||||
|
{% import _self as tree_macros %}
|
||||||
|
|
||||||
|
{# Render the tree inside the standard sidebar card. The container keeps the
|
||||||
|
generic id "editorFileTree" so shared CSS/JS selectors work everywhere. #}
|
||||||
|
<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> {{ treeHeader|default('Bestanden') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2" id="editorFileTree">
|
||||||
|
{% if files is empty %}
|
||||||
|
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
|
||||||
|
{% else %}
|
||||||
|
{{ tree_macros.tree(files, {
|
||||||
|
'relFile': relFile,
|
||||||
|
'editableExts': editableExts,
|
||||||
|
'csrfToken': csrf_token,
|
||||||
|
'treeIdPrefix': treeIdPrefix,
|
||||||
|
'treeRouteBase': treeRouteBase,
|
||||||
|
'treeRouteParams': treeRouteParams,
|
||||||
|
'treeDeleteRoute': treeDeleteRoute,
|
||||||
|
'treeDeleteName': treeDeleteName|default(''),
|
||||||
|
'treeDeleteValue': treeDeleteValue|default(''),
|
||||||
|
'treeMoveRoute': treeMoveRoute,
|
||||||
|
'treeMoveParams': treeMoveParams,
|
||||||
|
'treeDirActions': treeDirActions|default(false),
|
||||||
|
'treeHideActionsFor': treeHideActionsFor|default([]),
|
||||||
|
}) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var tree = document.getElementById('editorFileTree');
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
function findToggle(collapseEl) {
|
||||||
|
var id = collapseEl.id;
|
||||||
|
if (!id) return null;
|
||||||
|
return tree.querySelector('[data-bs-target="#' + id + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(btn) {
|
||||||
|
var icon = btn.querySelector('.tree-chevron');
|
||||||
|
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(btn) {
|
||||||
|
var icon = btn.querySelector('.tree-chevron');
|
||||||
|
if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.addEventListener('shown.bs.collapse', function (e) {
|
||||||
|
var btn = findToggle(e.target);
|
||||||
|
if (btn) expand(btn);
|
||||||
|
});
|
||||||
|
tree.addEventListener('hidden.bs.collapse', function (e) {
|
||||||
|
var btn = findToggle(e.target);
|
||||||
|
if (btn) collapse(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial state for folders that are open on page load
|
||||||
|
Array.prototype.forEach.call(tree.querySelectorAll('.tree-collapse.show'), function (c) {
|
||||||
|
var btn = findToggle(c);
|
||||||
|
if (btn) expand(btn);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
{# Reusable media modal, included by admin.twig when needs_editor is true.
|
{# Reusable media modal, included by admin.twig when needs_editor is true.
|
||||||
The editor toolbar "media" button opens this modal. It fetches the list
|
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
|
of media files from /admin/media-list (JSON) and renders them as a
|
||||||
snippet into the active CodeMirror editor at the cursor.
|
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:
|
Scope:
|
||||||
- Default (content editor): fetches /admin/media-list
|
- 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>
|
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
|
||||||
which scans plugins/<name>/assets/ instead of content/.
|
which scans plugins/<name>/assets/ instead of content/.
|
||||||
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
|
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
|
||||||
@@ -26,20 +29,20 @@
|
|||||||
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
|
<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') }}">
|
<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>
|
||||||
<div id="mediaModalGrid" class="row g-3">
|
<div id="mediaModalTree" class="media-tree-wrap">
|
||||||
<div class="col-12 text-center text-muted py-4" id="mediaModalLoading">
|
<div class="text-center text-muted py-4" id="mediaModalLoading">
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
|
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
|
||||||
</div>
|
</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>
|
<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.') }}
|
{{ mediaEmptyText|default('Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.') }}
|
||||||
</div>
|
</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>
|
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
|
||||||
<span id="mediaModalErrorText"></span>
|
<span id="mediaModalErrorText"></span>
|
||||||
</div>
|
</div>
|
||||||
{# Filled by JS #}
|
{# Tree populated by JS #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -49,6 +52,22 @@
|
|||||||
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -76,7 +95,6 @@
|
|||||||
}
|
}
|
||||||
return '<a href="' + url + '">' + name + '</a>';
|
return '<a href="' + url + '">' + name + '</a>';
|
||||||
}
|
}
|
||||||
// css, scss, js, json, etc.: just insert the URL
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,86 +103,167 @@
|
|||||||
return map[ext] || 'markdown';
|
return map[ext] || 'markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function iconFor(ext, isImage) {
|
||||||
var grid = document.getElementById('mediaModalGrid');
|
if (isImage) return 'bi-file-image text-info';
|
||||||
if (!grid) return;
|
if (ext === 'pdf') return 'bi-file-pdf text-danger';
|
||||||
|
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
|
||||||
// Clear previous items (keep loading/empty/error placeholders)
|
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
|
||||||
Array.prototype.forEach.call(grid.querySelectorAll('[data-media-item]'), function (el) { el.remove(); });
|
return 'bi-file text-muted';
|
||||||
|
|
||||||
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');
|
// Build a nested tree from a flat list of {path, name, ...} items.
|
||||||
body.className = 'card-body p-2';
|
function buildTree(flat) {
|
||||||
var label = document.createElement('p');
|
var root = { name: '', path: '', children: {}, isDir: true };
|
||||||
label.className = 'card-text small text-muted text-truncate mb-0';
|
flat.forEach(function (item) {
|
||||||
label.textContent = item.name;
|
var parts = item.path.split('/');
|
||||||
body.appendChild(label);
|
var node = root;
|
||||||
card.appendChild(body);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
card.addEventListener('click', function () {
|
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;
|
var editor = window.codeMirrorEditor;
|
||||||
if (!editor) { return; }
|
if (!editor) return;
|
||||||
var ext = (document.getElementById('editor-textarea') || {}).dataset;
|
var ext = (document.getElementById('editor-textarea') || {}).dataset;
|
||||||
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
|
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
|
||||||
var mode = modeForExt(fileExt);
|
var mode = modeForExt(fileExt);
|
||||||
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
|
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
|
||||||
editor.replaceSelection(snippet);
|
editor.replaceSelection(snippet);
|
||||||
editor.focus();
|
editor.focus();
|
||||||
// Close the modal
|
|
||||||
var modalEl = document.getElementById('mediaModal');
|
var modalEl = document.getElementById('mediaModal');
|
||||||
if (window.bootstrap && modalEl) {
|
if (window.bootstrap && modalEl) {
|
||||||
var inst = bootstrap.Modal.getInstance(modalEl);
|
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||||
if (inst) inst.hide();
|
if (inst) inst.hide();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
col.appendChild(card);
|
function renderTree() {
|
||||||
grid.appendChild(col);
|
var wrap = document.getElementById('mediaModalTree');
|
||||||
});
|
if (!wrap) return;
|
||||||
|
// Clear previous tree (keep placeholders)
|
||||||
// Toggle empty placeholder
|
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
|
||||||
var empty = document.getElementById('mediaModalEmpty');
|
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 loading = document.getElementById('mediaModalLoading');
|
||||||
|
var empty = document.getElementById('mediaModalEmpty');
|
||||||
if (loading) loading.classList.add('d-none');
|
if (loading) loading.classList.add('d-none');
|
||||||
if (empty) empty.classList.toggle('d-none', shown > 0);
|
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() {
|
function load() {
|
||||||
if (loaded) { renderGrid(); return; }
|
if (loaded) { renderTree(); return; }
|
||||||
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
||||||
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
||||||
var url = '/admin/media-list';
|
var url = '/admin/media-list';
|
||||||
@@ -178,7 +277,7 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
items = Array.isArray(data) ? data : [];
|
items = Array.isArray(data) ? data : [];
|
||||||
loaded = true;
|
loaded = true;
|
||||||
renderGrid();
|
renderTree();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
var loading = document.getElementById('mediaModalLoading');
|
var loading = document.getElementById('mediaModalLoading');
|
||||||
@@ -190,15 +289,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load when the modal is first shown
|
|
||||||
var modalEl = document.getElementById('mediaModal');
|
var modalEl = document.getElementById('mediaModal');
|
||||||
if (modalEl) {
|
if (modalEl) {
|
||||||
modalEl.addEventListener('show.bs.modal', load);
|
modalEl.addEventListener('show.bs.modal', load);
|
||||||
}
|
}
|
||||||
// Filter as the user types
|
|
||||||
var filter = document.getElementById('mediaModalFilter');
|
var filter = document.getElementById('mediaModalFilter');
|
||||||
if (filter) {
|
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>
|
</script>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
|
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{% include 'pages/_editor-styles.twig' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||||
|
|
||||||
@@ -11,12 +15,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
<label class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
<div class="input-group">
|
<p class="form-control-plaintext mb-0"><code>{{ fileName }}</code></p>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
|
|
||||||
<span class="input-group-text">.{{ fileExt }}</span>
|
|
||||||
</div>
|
|
||||||
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
|
||||||
</div>
|
</div>
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -29,31 +29,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if availablePlugins is not empty %}
|
{% if availablePlugins is not empty %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="plugin-checkbox-group">
|
||||||
{% for plugin in availablePlugins %}
|
{% for plugin in availablePlugins %}
|
||||||
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
<div class="form-check">
|
||||||
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if isEditable and (currentAuthorName or currentAuthorEmail or currentCreated) %}
|
{% if isEditable and (currentCreated or currentEdited) %}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
|
<label class="form-label text-muted"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</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>
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
@@ -67,11 +65,11 @@
|
|||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
||||||
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/admin/content-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +78,6 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var backBtn = document.getElementById('back-btn');
|
var backBtn = document.getElementById('back-btn');
|
||||||
var filenameInput = document.getElementById('filename');
|
|
||||||
var layoutSelect = document.getElementById('layout');
|
var layoutSelect = document.getElementById('layout');
|
||||||
if (!backBtn) return;
|
if (!backBtn) return;
|
||||||
|
|
||||||
@@ -94,10 +91,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
backBtn.classList.add('btn-outline-danger');
|
backBtn.classList.add('btn-outline-danger');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filenameInput) {
|
|
||||||
filenameInput.addEventListener('input', markChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the layout value in the editor's frontmatter when the select changes
|
// Update the layout value in the editor's frontmatter when the select changes
|
||||||
if (layoutSelect) {
|
if (layoutSelect) {
|
||||||
layoutSelect.addEventListener('change', function () {
|
layoutSelect.addEventListener('change', function () {
|
||||||
|
|||||||
@@ -2,123 +2,14 @@
|
|||||||
|
|
||||||
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
{# Recursively render the content file tree as a nested, collapsible list. #}
|
|
||||||
{% macro tree(nodes, relFile, editableExts, csrfToken) %}
|
|
||||||
{% import _self as macros %}
|
|
||||||
<ul class="plugin-tree">
|
|
||||||
{% for n in nodes %}
|
|
||||||
{% set isActive = n.path == relFile %}
|
|
||||||
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
|
|
||||||
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'html' ? 'bi-file-earmark text-info' : (n.extension in ['jpg','jpeg','png','gif','webp','svg'] ? 'bi-file-image text-info' : (n.extension == 'pdf' ? 'bi-file-pdf text-danger' : 'bi-file text-muted'))))) %}
|
|
||||||
<li>
|
|
||||||
{% if n.is_dir %}
|
|
||||||
{% set collapseId = 'content-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
|
||||||
<div class="tree-dir-row">
|
|
||||||
<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>
|
|
||||||
<span class="tree-dir-actions">
|
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
|
|
||||||
<i class="bi bi-file-earmark-plus"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
|
|
||||||
<i class="bi bi-folder-plus"></i>
|
|
||||||
</button>
|
|
||||||
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
|
|
||||||
<input type="hidden" name="dir" value="{{ n.path }}">
|
|
||||||
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div id="{{ collapseId }}"
|
|
||||||
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
|
|
||||||
{% if n.children is not empty %}
|
|
||||||
{{ macros.tree(n.children, relFile, editableExts, csrfToken) }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<a href="/admin/content?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/content-file-move?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/content-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="file" value="{{ n.path }}">
|
|
||||||
<button type="submit" class="btn btn-link btn-sm p-0 text-danger 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 %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||||
|
{% include 'pages/_editor-styles.twig' %}
|
||||||
<style>
|
<style>
|
||||||
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
/* Git status badge (content editor only) */
|
||||||
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
|
|
||||||
.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; }
|
|
||||||
.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; }
|
|
||||||
/* Directory action buttons (new file/dir, rename, delete) */
|
|
||||||
.tree-dir-row { position: relative; }
|
|
||||||
.tree-dir-row .tree-toggle { padding-right: 7rem; }
|
|
||||||
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
|
|
||||||
.tree-dir-actions .btn-link { color: #6c757d; }
|
|
||||||
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
|
|
||||||
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
|
|
||||||
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
|
|
||||||
/* Git status badge */
|
|
||||||
.git-status { font-size: .75rem; }
|
.git-status { font-size: .75rem; }
|
||||||
.git-status .badge { font-weight: 400; }
|
.git-status .badge { font-weight: 400; }
|
||||||
@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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -126,57 +17,22 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
<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.content_editor|default('Content editor') }}</h2>
|
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.content_editor|default('Content editor') }}</h2>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
{# Fase 5: backup/git integratie in zijbalk header #}
|
{# Backup integratie in zijbalk header (git wordt later een systeem plugin) #}
|
||||||
{% if gitStatus.available %}
|
|
||||||
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
||||||
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
||||||
</a>
|
</a>
|
||||||
{% if gitStatus.dirty %}
|
|
||||||
<form method="POST" action="/admin/content-git-commit" class="d-inline" onsubmit="return confirm('{{ ta.git_commit_confirm|default('Wijzigingen committen naar git?') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit" class="btn btn-outline-success btn-sm" title="{{ ta.git_commit|default('Git commit') }}">
|
|
||||||
<i class="bi bi-git"></i> {{ ta.git_commit|default('Commit') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<form method="POST" action="/admin/content-git-init" class="d-inline" onsubmit="return confirm('{{ ta.git_init_confirm|default('Git repository initialiseren in content/?') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{{ ta.git_init|default('Git init') }}">
|
|
||||||
<i class="bi bi-git"></i> {{ ta.git_init|default('Git init') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
|
|
||||||
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm">
|
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm">
|
||||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="">
|
<button type="button" class="btn btn-outline-primary btn-sm" id="newFileBtnTop" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="">
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||||
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content-list" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-list"></i> {{ ta.list_view|default('Lijst weergave') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Fase 5: git status badge bovenaan #}
|
|
||||||
{% if gitStatus.available %}
|
|
||||||
<div class="git-status mb-2 d-flex gap-2 align-items-center flex-wrap">
|
|
||||||
<span class="badge bg-secondary"><i class="bi bi-git"></i> {{ gitStatus.branch|default('git') }}</span>
|
|
||||||
{% if gitStatus.dirty %}
|
|
||||||
<span class="badge bg-warning text-dark">{{ ta.git_dirty|default('niet-committed wijzigingen') }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-success">{{ ta.git_clean|default('schone werkmap') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if gitStatus.lastCommit %}<span class="text-muted small">{{ ta.git_last_commit|default('laatste commit:') }} {{ gitStatus.lastCommit }}</span>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
|
|
||||||
{# Upload form (collapsed by default) #}
|
{# Upload form (collapsed by default) #}
|
||||||
<div class="collapse mb-3" id="contentUploadForm">
|
<div class="collapse mb-3" id="contentUploadForm">
|
||||||
@@ -198,25 +54,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-editor-layout">
|
<div class="editor-layout">
|
||||||
{# File tree sidebar #}
|
{# File tree sidebar (shared partial) #}
|
||||||
<div class="plugin-file-tree">
|
{% include 'pages/_file-tree.twig' with {
|
||||||
<div class="card shadow-sm h-100">
|
'files': files,
|
||||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
'relFile': relFile,
|
||||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.content_files|default('Content bestanden') }}</span>
|
'editableExts': editableExts,
|
||||||
</div>
|
'csrf_token': csrf_token,
|
||||||
<div class="card-body p-2" id="pluginFileTree">
|
'treeIdPrefix': 'content-tree',
|
||||||
{% if files is empty %}
|
'treeHeader': ta.content_files|default('Content bestanden'),
|
||||||
<div class="small text-muted p-2">{{ ta.content_no_files|default('Geen bestanden gevonden.') }}</div>
|
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
||||||
{% else %}
|
'treeRouteBase': '/admin/content',
|
||||||
{{ tree_macros.tree(files, relFile, editableExts, csrf_token) }}
|
'treeRouteParams': '',
|
||||||
{% endif %}
|
'treeDeleteRoute': '/admin/content-file-delete',
|
||||||
</div>
|
'treeDeleteName': '',
|
||||||
</div>
|
'treeDeleteValue': '',
|
||||||
</div>
|
'treeMoveRoute': '/admin/content-file-move',
|
||||||
|
'treeMoveParams': '',
|
||||||
|
'treeDirActions': true,
|
||||||
|
'treeHideActionsFor': [],
|
||||||
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="plugin-editor-main">
|
<div class="editor-main">
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
@@ -243,13 +103,16 @@
|
|||||||
{# Metadata row: filename display, layout selector, plugins #}
|
{# Metadata row: filename display, layout selector, plugins #}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
|
<label for="new_filename" class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
<p class="form-control-plaintext form-control-sm mb-0"><code>{{ fileName }}</code></p>
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" class="form-control" id="new_filename" name="new_filename" value="{{ fileBaseName }}" required>
|
||||||
|
<span class="input-group-text">.{{ fileExt }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if fileExt == 'md' %}
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }})</small></label>
|
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }})</small></label>
|
||||||
<select class="form-select form-select-sm" id="layout" name="layout">
|
<select class="form-select form-select-sm" id="layout" name="layout">
|
||||||
|
<option value="">{{ ta.theme_default|default('Thema standaard') }}</option>
|
||||||
{% for key, layoutFile in themeLayouts %}
|
{% for key, layoutFile in themeLayouts %}
|
||||||
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -257,33 +120,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if availablePlugins is not empty %}
|
{% if availablePlugins is not empty %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="plugin-checkbox-group">
|
||||||
{% for plugin in availablePlugins %}
|
{% for plugin in availablePlugins %}
|
||||||
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
<div class="form-check">
|
||||||
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if currentAuthorName or currentAuthorEmail or currentCreated %}
|
{# Created / edited timestamps (read-only, auto-filled) #}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
|
<label class="form-label text-muted small"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</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>
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted small"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||||
@@ -294,10 +153,9 @@
|
|||||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
||||||
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/content-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.list_view|default('Lijst weergave') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -385,10 +243,39 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var newFileModal = document.getElementById('newFileModal');
|
var newFileModal = document.getElementById('newFileModal');
|
||||||
var newDirModal = document.getElementById('newDirModal');
|
var newDirModal = document.getElementById('newDirModal');
|
||||||
|
var newFileBtnTop = document.getElementById('newFileBtnTop');
|
||||||
|
var newDirBtnTop = document.getElementById('newDirBtnTop');
|
||||||
|
|
||||||
|
// Track the selected directory for the top toolbar buttons
|
||||||
|
var selectedDir = '{{ selectedDir|default('') }}';
|
||||||
|
function updateTopButtons(dir) {
|
||||||
|
selectedDir = dir || '';
|
||||||
|
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||||
|
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make folder rows selectable: clicking the folder name selects it
|
||||||
|
var tree = document.getElementById('editorFileTree');
|
||||||
|
if (tree) {
|
||||||
|
tree.addEventListener('click', function (e) {
|
||||||
|
var toggle = e.target.closest('.tree-toggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
// Find the in-dir from the adjacent new-file button
|
||||||
|
var row = toggle.closest('.tree-dir-row');
|
||||||
|
if (!row) return;
|
||||||
|
var newFileBtn = row.querySelector('.tree-newfile-btn');
|
||||||
|
var dir = newFileBtn ? newFileBtn.getAttribute('data-in-dir') : '';
|
||||||
|
updateTopButtons(dir);
|
||||||
|
// Visually mark the selected folder
|
||||||
|
tree.querySelectorAll('.tree-toggle.is-selected').forEach(function (el) { el.classList.remove('is-selected'); });
|
||||||
|
toggle.classList.add('is-selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (newFileModal) {
|
if (newFileModal) {
|
||||||
newFileModal.addEventListener('show.bs.modal', function (event) {
|
newFileModal.addEventListener('show.bs.modal', function (event) {
|
||||||
var trigger = event.relatedTarget;
|
var trigger = event.relatedTarget;
|
||||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
|
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
||||||
var hidden = document.getElementById('newFileDir');
|
var hidden = document.getElementById('newFileDir');
|
||||||
if (hidden) hidden.value = inDir || '';
|
if (hidden) hidden.value = inDir || '';
|
||||||
// Show the in-dir prefix as a hint in the placeholder
|
// Show the in-dir prefix as a hint in the placeholder
|
||||||
@@ -402,12 +289,21 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (newDirModal) {
|
if (newDirModal) {
|
||||||
newDirModal.addEventListener('show.bs.modal', function (event) {
|
newDirModal.addEventListener('show.bs.modal', function (event) {
|
||||||
var trigger = event.relatedTarget;
|
var trigger = event.relatedTarget;
|
||||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
|
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
||||||
var hidden = document.getElementById('newDirInDir');
|
var hidden = document.getElementById('newDirInDir');
|
||||||
if (hidden) hidden.value = inDir;
|
if (hidden) hidden.value = inDir;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the initially selected folder (based on the opened file's directory)
|
||||||
|
if (selectedDir && tree) {
|
||||||
|
var initialBtn = tree.querySelector('.tree-newfile-btn[data-in-dir="' + selectedDir + '"]');
|
||||||
|
if (initialBtn) {
|
||||||
|
var initialToggle = initialBtn.closest('.tree-dir-row').querySelector('.tree-toggle');
|
||||||
|
if (initialToggle) initialToggle.classList.add('is-selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Layout select → update frontmatter (spiegel van content-edit.twig)
|
// Layout select → update frontmatter (spiegel van content-edit.twig)
|
||||||
var layoutSelect = document.getElementById('layout');
|
var layoutSelect = document.getElementById('layout');
|
||||||
if (layoutSelect) {
|
if (layoutSelect) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
<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>
|
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen / hernoemen') }}</h2>
|
||||||
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
|
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -15,22 +15,29 @@
|
|||||||
<input type="hidden" name="file" value="{{ relFile }}">
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
|
{{ ta.move_prefix|default('Verplaats / hernoem') }} <strong>{{ itemName }}{{ itemExt }}</strong>
|
||||||
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
|
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
|
||||||
{{ ta.move_to|default('naar:') }}
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_name" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="new_name" name="new_name" value="{{ itemName }}" required>
|
||||||
|
<span class="input-group-text">{{ itemExt }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">(content/)</small></label>
|
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">(content/)</small></label>
|
||||||
<select class="form-select" id="destination" name="destination" required>
|
<select class="form-select" id="destination" name="destination" required>
|
||||||
{% for directory in directories %}
|
{% for directory in directories %}
|
||||||
<option value="{{ directory }}">{{ directory == '' ? '(content root)' : directory }}</option>
|
<option value="{{ directory }}" {{ directory == itemDir ? 'selected' : '' }}>{{ directory == '' ? '(content root)' : directory }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white">
|
<div class="card-footer bg-white">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,99 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}{{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||||
<style>
|
{% include 'pages/_editor-styles.twig' %}
|
||||||
.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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -134,25 +45,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-editor-layout">
|
<div class="editor-layout">
|
||||||
{# File tree sidebar #}
|
{# File tree sidebar (shared partial) #}
|
||||||
<div class="plugin-file-tree">
|
{% include 'pages/_file-tree.twig' with {
|
||||||
<div class="card shadow-sm h-100">
|
'files': files,
|
||||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
'relFile': relFile,
|
||||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.plugin_files|default('Plugin bestanden') }}</span>
|
'editableExts': editableExts,
|
||||||
</div>
|
'csrf_token': csrf_token,
|
||||||
<div class="card-body p-2" id="pluginFileTree">
|
'treeIdPrefix': 'plugin-tree',
|
||||||
{% if files is empty %}
|
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
||||||
<div class="small text-muted p-2">{{ ta.plugin_no_files|default('Geen bestanden gevonden.') }}</div>
|
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
||||||
{% else %}
|
'treeRouteBase': '/admin/plugins-edit',
|
||||||
{{ tree_macros.tree(files, pluginName, relFile, editableExts, csrf_token) }}
|
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
{% endif %}
|
'treeDeleteRoute': '/admin/plugins-file-delete',
|
||||||
</div>
|
'treeDeleteName': 'plugin',
|
||||||
</div>
|
'treeDeleteValue': pluginName,
|
||||||
</div>
|
'treeMoveRoute': '/admin/plugins-file-move',
|
||||||
|
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
|
'treeDirActions': false,
|
||||||
|
'treeHideActionsFor': [],
|
||||||
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="plugin-editor-main">
|
<div class="editor-main">
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
|
|||||||
@@ -2,98 +2,12 @@
|
|||||||
|
|
||||||
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
{# Recursively render the theme 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, themeName, relFile, editableExts, csrfToken) %}
|
|
||||||
{% import _self as macros %}
|
|
||||||
<ul class="plugin-tree">
|
|
||||||
{% for n in nodes %}
|
|
||||||
{% set isActive = n.path == relFile %}
|
|
||||||
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
|
|
||||||
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'twig' ? 'bi-filetype-tfn text-info' : (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 = 'theme-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, themeName, relFile, editableExts, csrfToken) }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<a href="/admin/theme-edit?theme={{ themeName|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>
|
|
||||||
{% if n.path != 'theme.json' %}
|
|
||||||
<a href="/admin/theme-file-move?theme={{ themeName|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/theme-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="theme" value="{{ themeName }}">
|
|
||||||
<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 %}
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endmacro %}
|
|
||||||
{% import _self as tree_macros %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||||
|
{% include 'pages/_editor-styles.twig' %}
|
||||||
<style>
|
<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; }
|
|
||||||
.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; }
|
|
||||||
.theme-scss-status { font-size: .75rem; }
|
.theme-scss-status { font-size: .75rem; }
|
||||||
@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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -144,25 +58,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-editor-layout">
|
<div class="editor-layout">
|
||||||
{# File tree sidebar #}
|
{# File tree sidebar (shared partial) #}
|
||||||
<div class="plugin-file-tree">
|
{% include 'pages/_file-tree.twig' with {
|
||||||
<div class="card shadow-sm h-100">
|
'files': files,
|
||||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
'relFile': relFile,
|
||||||
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.theme_files|default('Thema bestanden') }}</span>
|
'editableExts': editableExts,
|
||||||
</div>
|
'csrf_token': csrf_token,
|
||||||
<div class="card-body p-2" id="pluginFileTree">
|
'treeIdPrefix': 'theme-tree',
|
||||||
{% if files is empty %}
|
'treeHeader': ta.theme_files|default('Thema bestanden'),
|
||||||
<div class="small text-muted p-2">{{ ta.theme_no_files|default('Geen bestanden gevonden.') }}</div>
|
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
|
||||||
{% else %}
|
'treeRouteBase': '/admin/theme-edit',
|
||||||
{{ tree_macros.tree(files, themeName, relFile, editableExts, csrf_token) }}
|
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
{% endif %}
|
'treeDeleteRoute': '/admin/theme-file-delete',
|
||||||
</div>
|
'treeDeleteName': 'theme',
|
||||||
</div>
|
'treeDeleteValue': themeName,
|
||||||
</div>
|
'treeMoveRoute': '/admin/theme-file-move',
|
||||||
|
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
|
'treeDirActions': false,
|
||||||
|
'treeHideActionsFor': ['theme.json'],
|
||||||
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="plugin-editor-main">
|
<div class="editor-main">
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
|
|||||||
+114
-61
@@ -90,8 +90,9 @@ class CodePressCMS {
|
|||||||
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes
|
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes
|
||||||
*/
|
*/
|
||||||
private function sanitizePageParam(string $page): string {
|
private function sanitizePageParam(string $page): string {
|
||||||
// Remove any characters that could be used for XSS
|
// Remove any characters that could be used for XSS; keep the dot so
|
||||||
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page);
|
// content file extensions (md/php/html) survive in the URL.
|
||||||
|
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
|
||||||
return $sanitized ?: 'invalid-page';
|
return $sanitized ?: 'invalid-page';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +331,8 @@ class CodePressCMS {
|
|||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries: dotfiles, -assets, _drafts, etc.
|
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
||||||
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
|
if ($item[0] === '.' || $item[0] === '-') continue;
|
||||||
|
|
||||||
// Skip assets directory (old name, kept for safety)
|
// Skip assets directory (old name, kept for safety)
|
||||||
if ($item === 'assets' && is_dir($dir . '/' . $item)) continue;
|
if ($item === 'assets' && is_dir($dir . '/' . $item)) continue;
|
||||||
@@ -359,12 +360,13 @@ class CodePressCMS {
|
|||||||
// Always use filename for navigation (not H1 titles from content)
|
// Always use filename for navigation (not H1 titles from content)
|
||||||
$filename = pathinfo($item, PATHINFO_FILENAME);
|
$filename = pathinfo($item, PATHINFO_FILENAME);
|
||||||
$title = $this->formatDisplayName($filename);
|
$title = $this->formatDisplayName($filename);
|
||||||
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
|
// Keep the extension in the URL so files sharing the same name
|
||||||
|
// but different type each keep their own viewable link.
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'type' => 'file',
|
'type' => 'file',
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'path' => $pathWithoutExt,
|
'path' => $relativePath,
|
||||||
'url' => $this->buildUrl($pathWithoutExt)
|
'url' => $this->buildUrl($relativePath)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,8 +400,8 @@ class CodePressCMS {
|
|||||||
$items = scandir($dir);
|
$items = scandir($dir);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries (consistent met scanDirectory)
|
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
||||||
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
|
if ($item[0] === '.' || $item[0] === '-') continue;
|
||||||
|
|
||||||
$path = $dir . '/' . $item;
|
$path = $dir . '/' . $item;
|
||||||
$relativePath = $prefix ? $prefix . '/' . $item : $item;
|
$relativePath = $prefix ? $prefix . '/' . $item : $item;
|
||||||
@@ -481,8 +483,13 @@ class CodePressCMS {
|
|||||||
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
|
||||||
// Limit length
|
// Limit length
|
||||||
$page = substr($page, 0, 255);
|
$page = substr($page, 0, 255);
|
||||||
// Only remove file extension at the end, not all dots
|
// Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
|
||||||
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page);
|
$explicitExt = null;
|
||||||
|
if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) {
|
||||||
|
$explicitExt = strtolower($extMatch[1]);
|
||||||
|
}
|
||||||
|
// Strip any content extension from the URL for the base path
|
||||||
|
$pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $page);
|
||||||
|
|
||||||
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
|
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
|
||||||
|
|
||||||
@@ -500,43 +507,32 @@ class CodePressCMS {
|
|||||||
|
|
||||||
$actualFilePath = null;
|
$actualFilePath = null;
|
||||||
|
|
||||||
// Check for exact file matches if no directory found
|
// If the URL has an explicit extension, try that exact file first
|
||||||
if (file_exists($filePath . '.md')) {
|
if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
|
||||||
$actualFilePath = $filePath . '.md';
|
$actualFilePath = $filePath . '.' . $explicitExt;
|
||||||
|
$result = $this->resolveContentByType($filePath, $explicitExt);
|
||||||
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
|
if ($result !== null) {
|
||||||
} elseif (file_exists($filePath . '.php')) {
|
$actualFilePath = $result['path'];
|
||||||
$actualFilePath = $filePath . '.php';
|
$result = $result['content'];
|
||||||
$result = $this->parsePHP($actualFilePath);
|
}
|
||||||
} elseif (file_exists($filePath . '.html')) {
|
}
|
||||||
$actualFilePath = $filePath . '.html';
|
// No explicit extension: serve the first matching file (md > php > html)
|
||||||
$result = $this->parseHTML(file_get_contents($actualFilePath));
|
if (!isset($result)) {
|
||||||
} elseif (file_exists($filePath)) {
|
$resolved = $this->resolveContentByType($filePath);
|
||||||
$actualFilePath = $filePath;
|
if ($resolved !== null) {
|
||||||
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
|
$actualFilePath = $resolved['path'];
|
||||||
if ($extension === 'md') {
|
$result = $resolved['content'];
|
||||||
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
|
|
||||||
} elseif ($extension === 'php') {
|
|
||||||
$result = $this->parsePHP($actualFilePath);
|
|
||||||
} elseif ($extension === 'html') {
|
|
||||||
$result = $this->parseHTML(file_get_contents($actualFilePath));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no exact match found, check for language-specific versions
|
// If no exact match found, check for language-specific versions
|
||||||
if (!isset($result)) {
|
if (!isset($result)) {
|
||||||
$result = null; // Reset result before language-specific search
|
|
||||||
$langPrefix = $this->currentLanguage;
|
$langPrefix = $this->currentLanguage;
|
||||||
|
$langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt;
|
||||||
if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) {
|
$resolved = $this->resolveContentByType($langBase, $explicitExt);
|
||||||
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md';
|
if ($resolved !== null) {
|
||||||
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
|
$actualFilePath = $resolved['path'];
|
||||||
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) {
|
$result = $resolved['content'];
|
||||||
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php';
|
|
||||||
$result = $this->parsePHP($actualFilePath);
|
|
||||||
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html')) {
|
|
||||||
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html';
|
|
||||||
$result = $this->parseHTML(file_get_contents($actualFilePath));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +549,40 @@ class CodePressCMS {
|
|||||||
return $this->getError404();
|
return $this->getError404();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the first content file matching a base path by type priority.
|
||||||
|
*
|
||||||
|
* A request without explicit extension (e.g. "test") serves the first
|
||||||
|
* existing file among test.md → test.php → test.html. When $preferredExt
|
||||||
|
* is given (the URL carried an extension), that type is tried first so
|
||||||
|
* /nl/test.php keeps serving test.php even when test.md also exists.
|
||||||
|
*
|
||||||
|
* @param string $basePath Absolute path without extension
|
||||||
|
* @param string $preferredExt Optional extension to try first (md|php|html)
|
||||||
|
* @return array|null ['path' => string, 'content' => array] or null
|
||||||
|
*/
|
||||||
|
private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array
|
||||||
|
{
|
||||||
|
$order = ['md', 'php', 'html'];
|
||||||
|
if ($preferredExt !== null && in_array($preferredExt, $order, true)) {
|
||||||
|
$order = array_unique(array_merge([$preferredExt], $order));
|
||||||
|
}
|
||||||
|
foreach ($order as $ext) {
|
||||||
|
$candidate = $basePath . '.' . $ext;
|
||||||
|
if (file_exists($candidate)) {
|
||||||
|
if ($ext === 'md') {
|
||||||
|
$content = $this->parseMarkdown(file_get_contents($candidate), $candidate);
|
||||||
|
} elseif ($ext === 'php') {
|
||||||
|
$content = $this->parsePHP($candidate);
|
||||||
|
} else {
|
||||||
|
$content = $this->parseHTML(file_get_contents($candidate));
|
||||||
|
}
|
||||||
|
return ['path' => $candidate, 'content' => $content];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file information including creation and modification dates
|
* Get file information including creation and modification dates
|
||||||
*
|
*
|
||||||
@@ -836,7 +866,11 @@ class CodePressCMS {
|
|||||||
private function autoLinkPageTitles($content, $excludeTitle = '') {
|
private function autoLinkPageTitles($content, $excludeTitle = '') {
|
||||||
$pages = $this->getAllPageTitles();
|
$pages = $this->getAllPageTitles();
|
||||||
|
|
||||||
foreach ($pages as $pagePath => $pageTitle) {
|
foreach ($pages as $page) {
|
||||||
|
$pageTitle = $page['title'];
|
||||||
|
$pagePath = $page['path'];
|
||||||
|
// Alleen bestanden auto-linken, geen mappen
|
||||||
|
if ($page['type'] === 'folder') continue;
|
||||||
if (strtolower($pageTitle) === strtolower($excludeTitle)) {
|
if (strtolower($pageTitle) === strtolower($excludeTitle)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -863,9 +897,10 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all page titles from content directory
|
* Get all content entries (mappen + bestanden) from the content directory.
|
||||||
*
|
*
|
||||||
* @return array Associative array of page paths to titles
|
* @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...]
|
||||||
|
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
||||||
*/
|
*/
|
||||||
public function getAllPageTitles() {
|
public function getAllPageTitles() {
|
||||||
$pages = [];
|
$pages = [];
|
||||||
@@ -874,7 +909,13 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively scan for page titles in directory
|
* Recursively scan for content entries in a directory.
|
||||||
|
*
|
||||||
|
* Returns a flat list of ['path' => relativePath, 'title' => ..., 'type' => ...].
|
||||||
|
* Mappen krijgen type 'folder'; bestanden krijgen hun extensie als type.
|
||||||
|
* Bestanden met dezelfde naam maar ander type blijven apart (de extensie
|
||||||
|
* zit in het pad), en mappen krijgen hun eigen entry zodat de boom zichtbaar
|
||||||
|
* blijft.
|
||||||
*
|
*
|
||||||
* @param string $dir Directory to scan
|
* @param string $dir Directory to scan
|
||||||
* @param string $prefix Relative path prefix
|
* @param string $prefix Relative path prefix
|
||||||
@@ -888,26 +929,30 @@ class CodePressCMS {
|
|||||||
sort($items);
|
sort($items);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries (consistent met scanDirectory)
|
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
||||||
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
|
if ($item[0] === '.' || $item[0] === '-') continue;
|
||||||
|
|
||||||
$path = $dir . '/' . $item;
|
$path = $dir . '/' . $item;
|
||||||
$relativePath = $prefix ? $prefix . '/' . $item : $item;
|
$relativePath = $prefix ? $prefix . '/' . $item : $item;
|
||||||
|
|
||||||
if (is_dir($path)) {
|
if (is_dir($path)) {
|
||||||
|
$pages[] = [
|
||||||
|
'path' => $relativePath,
|
||||||
|
'title' => $this->formatDisplayName($item),
|
||||||
|
'type' => 'folder',
|
||||||
|
];
|
||||||
$this->scanForPageTitles($path, $relativePath, $pages);
|
$this->scanForPageTitles($path, $relativePath, $pages);
|
||||||
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
|
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
|
||||||
$title = $this->extractPageTitle($path);
|
$title = $this->extractPageTitle($path);
|
||||||
if ($title && !empty(trim($title))) {
|
if (!$title || empty(trim($title))) {
|
||||||
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
|
|
||||||
$pages[$pagePath] = $title;
|
|
||||||
} else {
|
|
||||||
// Fallback to clean filename if no title found in content
|
|
||||||
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
|
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
|
||||||
$cleanName = $this->formatDisplayName($filename);
|
$title = $this->formatDisplayName($filename);
|
||||||
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
|
|
||||||
$pages[$pagePath] = $cleanName;
|
|
||||||
}
|
}
|
||||||
|
$pages[] = [
|
||||||
|
'path' => $relativePath,
|
||||||
|
'title' => $title,
|
||||||
|
'type' => pathinfo($item, PATHINFO_EXTENSION),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,9 +1047,15 @@ class CodePressCMS {
|
|||||||
// Make API and metadata available to the included file
|
// Make API and metadata available to the included file
|
||||||
$api = new ContentAPI($this);
|
$api = new ContentAPI($this);
|
||||||
$pageMetadata = $metadata;
|
$pageMetadata = $metadata;
|
||||||
include $filePath;
|
$returnedContent = include $filePath;
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
// If the PHP file returned a string, use that as content (callback style).
|
||||||
|
// Otherwise use the buffered echo output.
|
||||||
|
if (is_string($returnedContent) && $returnedContent !== '') {
|
||||||
|
$content = $returnedContent;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any remaining metadata from PHP output
|
// Remove any remaining metadata from PHP output
|
||||||
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
|
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
|
||||||
|
|
||||||
@@ -1232,7 +1283,8 @@ class CodePressCMS {
|
|||||||
$allItems = [];
|
$allItems = [];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if ($item[0] === '.') continue;
|
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
||||||
|
if ($item[0] === '.' || $item[0] === '-') continue;
|
||||||
|
|
||||||
$itemPath = $dirPath . '/' . $item;
|
$itemPath = $dirPath . '/' . $item;
|
||||||
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
|
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
|
||||||
@@ -1246,15 +1298,16 @@ class CodePressCMS {
|
|||||||
'type' => 'directory'
|
'type' => 'directory'
|
||||||
];
|
];
|
||||||
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
|
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
|
||||||
|
// Each file keeps its own link (incl. extension) so every type
|
||||||
|
// stays viewable at the frontend even when names overlap.
|
||||||
$extractedTitle = $this->extractPageTitle($itemPath);
|
$extractedTitle = $this->extractPageTitle($itemPath);
|
||||||
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
|
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
|
||||||
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
|
|
||||||
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
|
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
|
||||||
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
|
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
|
||||||
$allItems[] = [
|
$allItems[] = [
|
||||||
'name' => $fileTitle,
|
'name' => $fileTitle,
|
||||||
'path' => $pathWithoutExt,
|
'path' => $relativePath,
|
||||||
'url' => $this->buildUrl($pathWithoutExt),
|
'url' => $this->buildUrl($relativePath),
|
||||||
'icon' => $icon,
|
'icon' => $icon,
|
||||||
'type' => 'file'
|
'type' => 'file'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ class ContentAPI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all pages as a flat array of path => title pairs
|
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
||||||
*
|
*
|
||||||
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons']
|
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
||||||
|
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
@@ -29,18 +30,32 @@ class ContentAPI
|
|||||||
/**
|
/**
|
||||||
* Get a single page by path, including title, content, layout, and metadata
|
* Get a single page by path, including title, content, layout, and metadata
|
||||||
*
|
*
|
||||||
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1')
|
* De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
|
||||||
|
* Zonder extensie wordt het eerste bestaande bestand gekozen (md > php > html);
|
||||||
|
* met extensie wordt dat specifieke bestand gekozen.
|
||||||
|
*
|
||||||
|
* @param string $path Page path, al dan niet met extensie
|
||||||
* @return array|null Page data or null if not found
|
* @return array|null Page data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getPage(string $path): ?array
|
public function getPage(string $path): ?array
|
||||||
{
|
{
|
||||||
$contentDir = $this->cms->config['content_dir'];
|
$contentDir = $this->cms->config['content_dir'];
|
||||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
|
||||||
|
// Detecteer expliciete extensie en strip hem voor de basis
|
||||||
|
$preferredExt = null;
|
||||||
|
if (preg_match('/\.(md|php|html)$/i', $path, $m)) {
|
||||||
|
$preferredExt = strtolower($m[1]);
|
||||||
|
}
|
||||||
|
$path = preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||||
$filePath = $contentDir . '/' . $path;
|
$filePath = $contentDir . '/' . $path;
|
||||||
|
|
||||||
$extensions = ['md', 'php', 'html'];
|
// Probeer de gevraagde extensie eerst, dan de standaard volgorde
|
||||||
|
$order = ['md', 'php', 'html'];
|
||||||
|
if ($preferredExt !== null) {
|
||||||
|
$order = array_unique(array_merge([$preferredExt], $order));
|
||||||
|
}
|
||||||
$actualPath = null;
|
$actualPath = null;
|
||||||
foreach ($extensions as $ext) {
|
foreach ($order as $ext) {
|
||||||
if (file_exists($filePath . '.' . $ext)) {
|
if (file_exists($filePath . '.' . $ext)) {
|
||||||
$actualPath = $filePath . '.' . $ext;
|
$actualPath = $filePath . '.' . $ext;
|
||||||
break;
|
break;
|
||||||
@@ -141,14 +156,13 @@ class ContentAPI
|
|||||||
/**
|
/**
|
||||||
* Check if a page exists at the given path
|
* Check if a page exists at the given path
|
||||||
*
|
*
|
||||||
* @param string $path Page path without extension
|
* @param string $path Page path, al dan niet met extensie
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function pageExists(string $path): bool
|
public function pageExists(string $path): bool
|
||||||
{
|
{
|
||||||
$contentDir = $this->cms->config['content_dir'];
|
$contentDir = $this->cms->config['content_dir'];
|
||||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
$basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||||
$basePath = $contentDir . '/' . $path;
|
|
||||||
|
|
||||||
return file_exists($basePath . '.md')
|
return file_exists($basePath . '.md')
|
||||||
|| file_exists($basePath . '.php')
|
|| file_exists($basePath . '.php')
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all pages with their metadata
|
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
||||||
|
*
|
||||||
|
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# CodePress CMS v2.6.3 — Release Notes
|
||||||
|
|
||||||
|
**Release datum:** 2026-08-20
|
||||||
|
**Codename:** Lyra
|
||||||
|
**Status:** stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
Deze release richt zich op **content met meerdere bestandstypes** en een
|
||||||
|
**duidelijkere Content API**. Content bestanden die dezelfde naam delen maar een
|
||||||
|
ander type hebben (`test.md`, `test.php`, `test.html`) worden nu correct
|
||||||
|
afgehandeld: de frontend serveert per extensie het juiste bestand, en de admin
|
||||||
|
accepteert en toont ze allemaal. Daarnaast is de `getAllPages()` API herschreven
|
||||||
|
naar een rijkere array-structuur en zijn de verberg-prefixen voor mappen/bestanden
|
||||||
|
gewijzigd (`.` vervangt `_`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nieuwe features & wijzigingen
|
||||||
|
|
||||||
|
### 1. Content bestanden met dezelfde naam, ander type
|
||||||
|
|
||||||
|
Eerder kon een content map niet twee bestanden bevatten met dezelfde naam maar
|
||||||
|
een verschillend type (bijv. `test.md` + `test.php`). De frontend routeerde
|
||||||
|
alleen op naam zonder extensie, waardoor slechts één bestand geserveerd werd.
|
||||||
|
|
||||||
|
Nu:
|
||||||
|
|
||||||
|
- **URL met extensie** (`/nl/test.php`) serveert exact dat bestand.
|
||||||
|
- **URL zonder extensie** (`/nl/test`) valt terug op het eerste bestaande bestand
|
||||||
|
met prioriteit **md > php > html** (via de nieuwe `resolveContentByType()`
|
||||||
|
helper).
|
||||||
|
- De admin content editor accepteert bestanden met dezelfde naam (ander type);
|
||||||
|
de "bestaat al" check is per extensie. De melding is verduidelijkt naar
|
||||||
|
"Bestand met dit type bestaat al."
|
||||||
|
- De admin preview-knop linkt per extensie (`test.php` → `/nl/test.php`) zodat
|
||||||
|
elk type individueel te bekijken blijft.
|
||||||
|
|
||||||
|
### 2. Frontend navigatie tonen alle bestandstypes
|
||||||
|
|
||||||
|
`scanDirectory()` (menu), `getDirectoryListing()` (directory view) en
|
||||||
|
`searchInDirectory()` (zoeken) tonen nu elk bestand apart met de extensie
|
||||||
|
behouden in de URL. Bestanden met dezelfde naam maar ander type zijn niet meer
|
||||||
|
samengevoegd tot één menu-item — elk type krijgt zijn eigen link.
|
||||||
|
|
||||||
|
### 3. getAllPages() array structuur
|
||||||
|
|
||||||
|
`ContentAPI::getAllPages()` (en `CMSAPI::getAllPages()` voor plugins) gaf eerder
|
||||||
|
een associative array `['pad' => 'titel']`. Nu is het een **list van entry
|
||||||
|
arrays**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
['path' => 'test-map', 'title' => 'Test Map', 'type' => 'folder'],
|
||||||
|
['path' => 'test-map/test.md','title' => 'Nieuwe pagina', 'type' => 'md'],
|
||||||
|
['path' => 'test.md', 'title' => 'Test', 'type' => 'md'],
|
||||||
|
['path' => 'test.php', 'title' => 'Test.', 'type' => 'php'],
|
||||||
|
['path' => 'test.html', 'title' => 'Nieuwe pagina', 'type' => 'html'],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||||
|
- Mappen krijgen hun eigen entry zodat de boom-structuur zichtbaar blijft.
|
||||||
|
- Bestanden met dezelfde naam maar ander type botsen niet meer (de extensie
|
||||||
|
zit in het pad).
|
||||||
|
|
||||||
|
`autoLinkPageTitles()` is aangepast aan de nieuwe structuur en slaat mappen over.
|
||||||
|
|
||||||
|
### 4. Verberg-prefix logica: `.` i.p.v. `_`
|
||||||
|
|
||||||
|
Vooraf verborg de frontend mappen/bestanden die begonnen met `.`, `-` of `_`.
|
||||||
|
Nu is `_` **geen** verberg-prefix meer — alleen `.` (en `-`) verbergen aan de
|
||||||
|
frontend. Dit maakt `.map` of `.bak` de standaard manier om content te verbergen.
|
||||||
|
|
||||||
|
De admin daarentegen toont **wél** alle `.` bestanden en mappen (`.bak`,
|
||||||
|
`.map`), behalve `.git`/`.gitkeep`. De `scanEditorFilesNode()` functie heeft een
|
||||||
|
nieuwe `$scope` parameter die bepaalt of dotfiles getoond worden (alleen voor
|
||||||
|
`content` scope).
|
||||||
|
|
||||||
|
`collectContentPages()` (admin config default-page dropdown) toont nu ook `.`
|
||||||
|
bestanden.
|
||||||
|
|
||||||
|
### 5. ContentAPI getPage()/pageExists() met extensie
|
||||||
|
|
||||||
|
`ContentAPI::getPage('test.php')` en `pageExists('test.php')` respecteren nu de
|
||||||
|
expliciete extensie — ze openen dat specifieke bestand in plaats van terug te
|
||||||
|
vallen op md-prioriteit.
|
||||||
|
|
||||||
|
### 6. Handleiding content-api.md herschreven
|
||||||
|
|
||||||
|
De handleiding `guide/nl/content-beheerder/content-api.md` en de Engelse
|
||||||
|
tegenhanger zijn volledig herschreven:
|
||||||
|
|
||||||
|
- **Hoe content naar Twig gaat**: echo/print vs return mechanisme duidelijk
|
||||||
|
uitgelegd met voorbeelden
|
||||||
|
- **Wat je niet kunt**: geen eigen Twig variabelen vanuit PHP; je voedt alleen
|
||||||
|
`{{ content }}`
|
||||||
|
- **Beschikbare variabelen** in een `.php` bestand: `$api` en `$pageMetadata`
|
||||||
|
- **Nieuwe getAllPages() structuur** met type-veld en voorbeelden
|
||||||
|
- **Layout kiezen**: hoe de `layout` frontmatter key koppelt naar `theme.json`
|
||||||
|
- **Veiligheidsrichtlijnen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische details
|
||||||
|
|
||||||
|
### Nieuwe/gewijzigde functies (core)
|
||||||
|
|
||||||
|
- `CodePressCMS::resolveContentByType(string $basePath, ?string $preferredExt = null): ?array`
|
||||||
|
— Nieuwe helper die het eerste bestaande bestand (md > php > html) serveert,
|
||||||
|
of het bestand met de gevraagde extensie als `$preferredExt` opgegeven is.
|
||||||
|
- `CodePressCMS::scanForPageTitles()` — herschreven naar list van
|
||||||
|
`['path','title','type']` entries; mappen als eigen entry; `_` niet meer
|
||||||
|
verborgen.
|
||||||
|
- `CodePressCMS::scanDirectory()` / `searchInDirectory()` / `getDirectoryListing()`
|
||||||
|
— `_` verwijderd uit verberg-logica; extensie behouden in URLs.
|
||||||
|
- `CodePressCMS::autoLinkPageTitles()` — aangepast aan nieuwe array structuur.
|
||||||
|
- `ContentAPI::getPage()` / `pageExists()` — respecteren expliciete extensie.
|
||||||
|
- `ContentAPI::getAllPages()` / `CMSAPI::getAllPages()` — docblocks bijgewerkt.
|
||||||
|
- `scanEditorFilesNode()` — nieuwe `$scope` parameter; content-scope toont
|
||||||
|
dotfiles (behalve .git/.gitkeep).
|
||||||
|
- `scanContentDir()` / `collectContentPages()` — tonen nu `.` bestanden/mappen.
|
||||||
|
|
||||||
|
### Gewijzigde bestanden
|
||||||
|
|
||||||
|
- `cms/core/class/CodePressCMS.php` — resolveContentByType, scanForPageTitles,
|
||||||
|
scanDirectory, searchInDirectory, getDirectoryListing, autoLinkPageTitles,
|
||||||
|
sanitizePageParam (punt toegestaan), getPage (explicitExt terug)
|
||||||
|
- `cms/core/class/ContentAPI.php` — getPage/pageExists met extensie, getAllPages docblock
|
||||||
|
- `cms/core/plugin/CMSAPI.php` — getAllPages docblock
|
||||||
|
- `public/admin.php` — scanContentDir, scanEditorFilesNode, collectContentPages,
|
||||||
|
handleContentNew/handleContentFiles meldingen
|
||||||
|
- `admin/theme/default/views/pages/content-files.twig` — preview link per extensie
|
||||||
|
- `admin/theme/default/views/pages/content-edit.twig` — preview link per extensie
|
||||||
|
- `guide/nl/content-beheerder/content-api.md` — herschreven
|
||||||
|
- `guide/en/content-beheerder/content-api.md` — herschreven
|
||||||
|
- `version.php` — 2.6.3
|
||||||
|
- `README.md` / `README.en.md` — versie 2.6.3
|
||||||
|
- `TODO.md` — v2.6.3 sectie toegevoegd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- PHP lint: alle bestanden syntactisch correct (`php -l`)
|
||||||
|
- Live getest via Apache (development.codepress.noorlander.info):
|
||||||
|
- `/nl/test.php` serveert test.php
|
||||||
|
- `/nl/test.html` serveert test.html
|
||||||
|
- `/nl/test` (zonder ext) serveert test.md (md prioriteit)
|
||||||
|
- Admin content boom toont `.bak`, `.map`, `_images` en alle bestandstypes
|
||||||
|
- Frontend navigatie verbergt `.bak`/`.map`, toont alle bestandstypes apart
|
||||||
|
- `$api->getAllPages()` in test.php toont pad/titel/type structuur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volgende stappen
|
||||||
|
|
||||||
|
Zie `TODO.md` voor openstaand werk (multidomein implementatie).
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
# Content management
|
# Content management
|
||||||
|
|
||||||
CodePress offers two views for content management:
|
CodePress uses a **tree view** (`/admin/content`) for content management: a file browser sidebar + CodeMirror editor, uniform with the plugin and theme editors. The old list/table view has been removed.
|
||||||
|
|
||||||
1. **Tree view** (`/admin/content`) — default, file browser sidebar + CodeMirror editor (uniform with plugin/theme editors)
|
## Content editor
|
||||||
2. **List view** (`/admin/content-list`) — classic table with filter, upload, new folder/file per folder
|
|
||||||
|
|
||||||
Both work side-by-side and use the same content directory. Switch between views via the "List view"/"Tree view" buttons at the top right. The tree view offers the same functionality as the list view, but with a nested file-tree sidebar like the plugin and theme editors — for a consistent editor experience.
|
|
||||||
|
|
||||||
## Tree view (content)
|
|
||||||
|
|
||||||
### File browser sidebar
|
### File browser sidebar
|
||||||
- Shows the nested file tree of `content/`
|
- Shows the nested file tree of `content/`
|
||||||
@@ -19,6 +14,30 @@ Both work side-by-side and use the same content directory. Switch between views
|
|||||||
### Editable file types
|
### Editable file types
|
||||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
|
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
|
||||||
|
|
||||||
|
All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
|
||||||
|
|
||||||
|
#### PHP content files
|
||||||
|
PHP files can deliver content in two ways:
|
||||||
|
1. **Echo** (output buffering): everything `echo`ed or outside `<?php` tags is used as content
|
||||||
|
2. **Return** (callback style): `return '<h1>Hello</h1>';` — the returned string is used as content
|
||||||
|
|
||||||
|
In PHP files, the `ContentAPI` is available via `$api`:
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$menu = $api->getMenu();
|
||||||
|
$pages = $api->getAllPages();
|
||||||
|
$config = $api->getConfig('site_title');
|
||||||
|
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend URLs and file names
|
||||||
|
The CMS searches files in order: `.md` → `.php` → `.html`. If files share the same name (e.g. `test.md` and `test.php`), `/nl/test` always opens `test.md`. To open a specific file type, add the extension to the URL:
|
||||||
|
- `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
|
||||||
|
- `/nl/test.php` → opens `test.php` (explicit extension)
|
||||||
|
- `/nl/test.html` → opens `test.html` (explicit extension)
|
||||||
|
|
||||||
|
The preview link in the editor automatically uses the extension for non-`.md` files.
|
||||||
|
|
||||||
### Set image size (Markdown)
|
### Set image size (Markdown)
|
||||||
The markdown editor toolbar has an "Image size" button (expand icon):
|
The markdown editor toolbar has an "Image size" button (expand icon):
|
||||||
1. Select an image in the editor in markdown format ``
|
1. Select an image in the editor in markdown format ``
|
||||||
@@ -41,7 +60,7 @@ Images in `content/` are served via the `/-media/` endpoint (content/ lives outs
|
|||||||
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
|
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
|
||||||
- Choose the file type (Markdown/PHP/HTML)
|
- Choose the file type (Markdown/PHP/HTML)
|
||||||
- Subfolders are created automatically
|
- Subfolders are created automatically
|
||||||
- Frontmatter with `layout`, `author_name`, `author_email`, `created` is auto-generated
|
- Frontmatter with `layout`, `created`, `edited` is auto-generated
|
||||||
|
|
||||||
### Upload a file
|
### Upload a file
|
||||||
- Click **Upload** to upload files to `content/`
|
- Click **Upload** to upload files to `content/`
|
||||||
@@ -62,27 +81,22 @@ Images in `content/` are served via the `/-media/` endpoint (content/ lives outs
|
|||||||
On the editor page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key.
|
On the editor page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key.
|
||||||
|
|
||||||
### Plugins on pages
|
### Plugins on pages
|
||||||
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection
|
- Only active **content plugins** (from `plugin.json` with `type: "content"`) appear in the plugin multiselect; system plugins (like Statistics/Logs/Dashboard) are excluded
|
||||||
- Choose which plugins run on the page
|
- Choose which plugins run on the page via the multiselect (use Ctrl/Cmd+click for multiple)
|
||||||
- The plugin selection is stored in the frontmatter `plugins:` key
|
- The plugin selection is stored in the frontmatter `plugins:` key
|
||||||
|
|
||||||
### Git / Backup integration (Phase 5)
|
### Backup integration
|
||||||
At the top of the content editor there are backup and git actions:
|
At the top of the content editor there is a **Backup** button linking to the backup page (`/admin/content-backup`) for ZIP backup/restore and (if available) git versioning. Git integration in the editor sidebar has been removed; git will become a system plugin later.
|
||||||
- **Git init**: initializes a git repository in `content/` (if none exists yet)
|
|
||||||
- **Commit**: commits all uncommitted changes (only if there is a git repo and there are changes)
|
|
||||||
- **Backup**: link to the backup page (`/admin/content-backup`) for ZIP backup/restore
|
|
||||||
- The git status badge shows the current branch, whether there are uncommitted changes, and the last commit
|
|
||||||
|
|
||||||
### Frontmatter
|
### Frontmatter
|
||||||
|
|
||||||
The editor updates the frontmatter live when layout or plugin selection changes:
|
The editor shows the **Created** and **Edited** timestamps (read-only, automatically updated on every save). The `edited:` value is refreshed on each save.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
author_name: Admin
|
|
||||||
author_email: admin@example.com
|
|
||||||
created: 2026-08-19 10:30:25
|
created: 2026-08-19 10:30:25
|
||||||
|
edited: 2026-08-20 14:22:01
|
||||||
plugins: HTMLBlock, Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,24 +105,6 @@ plugins: HTMLBlock, Navigation
|
|||||||
Content...
|
Content...
|
||||||
```
|
```
|
||||||
|
|
||||||
## List view (content)
|
|
||||||
|
|
||||||
### Managing files
|
|
||||||
|
|
||||||
- **New folder** — Create folder structure
|
|
||||||
- **New file** — Create a page (`.md`, `.php`, `.html`)
|
|
||||||
- **Edit** — Modify existing content in the CodeMirror editor
|
|
||||||
- **Rename** — Change file or folder names
|
|
||||||
- **Move** — Move content to another folder
|
|
||||||
- **Delete** — Remove content
|
|
||||||
- **Rename folder** — Change a folder name
|
|
||||||
|
|
||||||
### Editor (content-edit)
|
|
||||||
|
|
||||||
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML)
|
|
||||||
- **Toolbar** for quickly inserting Markdown
|
|
||||||
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
The editor updates the frontmatter live when layout or plugin selection changes:
|
The editor updates the frontmatter live when layout or plugin selection changes:
|
||||||
@@ -116,9 +112,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
plugins:
|
created: 2026-08-19 10:30:25
|
||||||
- HTMLBlock
|
edited: 2026-08-20 14:22:01
|
||||||
- Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Page title
|
# Page title
|
||||||
|
|||||||
@@ -1,84 +1,132 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
The Content API is available in PHP content files (`.php`) and provides a safe, read-only interface to CMS data.
|
PHP content files (`.php`) run inside the CMS and have access to a safe,
|
||||||
|
read-only API via the `$api` variable. The output of such a file is
|
||||||
|
automatically placed into the active Twig layout at the `{{ content }}` spot.
|
||||||
|
|
||||||
## Usage in PHP content files
|
## How to pass content to the Twig template
|
||||||
|
|
||||||
|
There are **two ways** to send content from a `.php` file to the Twig template.
|
||||||
|
The CMS picks the right one automatically:
|
||||||
|
|
||||||
|
1. **Echo / print** — everything you echo (or that sits outside `<?php ?>` tags)
|
||||||
|
is captured and placed as `{{ content }}` in the layout.
|
||||||
|
2. **Return** — if you `return` a string, that string is used as `{{ content }}`
|
||||||
|
(any echoed output is then ignored).
|
||||||
|
|
||||||
|
Example with echo:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
|
?>
|
||||||
// Get all pages
|
<h1>Hello <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
$allPages = $api->getAllPages();
|
<p>Welcome to my page.</p>
|
||||||
// Result: ['index' => 'Home', 'about-us' => 'About Us', ...]
|
|
||||||
|
|
||||||
// Get a specific page
|
|
||||||
$page = $api->getPage('about-us');
|
|
||||||
// Result: ['title' => 'About Us', 'content' => '...', 'path' => 'about-us', 'layout' => 'full_content', 'metadata' => [...]]
|
|
||||||
|
|
||||||
// Get menu structure
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
// Result: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
|
||||||
|
|
||||||
// Get config value (dot notation)
|
|
||||||
$siteTitle = $api->getConfig('site_title');
|
|
||||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available methods
|
Example with return:
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
return '<h1>Hello ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
> The frontmatter (`---` block) is stripped by the CMS before the PHP code
|
||||||
|
> runs. The layout key determines which Twig template surrounds the content.
|
||||||
|
|
||||||
|
## What you cannot do
|
||||||
|
|
||||||
|
- You **cannot** set your own Twig variables from a `.php` file. PHP content
|
||||||
|
only feeds the `{{ content }}` placeholder. Other template variables
|
||||||
|
(`menu`, `breadcrumb`, `page_title`, etc.) are set by the CMS based on
|
||||||
|
frontmatter and config — not by your PHP code.
|
||||||
|
- You **cannot** call a ContentAPI from the outside; the class is only
|
||||||
|
instantiated inside `parsePHP()` and is never reachable via a URL.
|
||||||
|
|
||||||
|
## Available variables in your PHP file
|
||||||
|
|
||||||
|
The following variables are available inside a `.php` content file:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `$api` | `ContentAPI` | Read-only access to CMS data |
|
||||||
|
| `$pageMetadata` | `array` | The frontmatter metadata of this file |
|
||||||
|
|
||||||
|
## ContentAPI methods
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
- `getAllPages(): array` - All pages as `['path' => 'title']` pairs
|
- `getAllPages(): array` — All content entries (folders + files) as a list of `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` for files, `folder` for directories.
|
||||||
- `getPage(string $path): ?array` - Specific page with `title`, `content`, `path`, `layout`, `metadata`
|
- `getPage(string $path): ?array` — A specific page; returns `title`, `content`, `path`, `layout`, `metadata`. `$path` may include an extension.
|
||||||
- `pageExists(string $path): bool` - Check if a page exists
|
- `pageExists(string $path): bool` — Check whether a page exists
|
||||||
- `getCurrentPageTitle(): string` - Title of current page
|
- `getCurrentPageTitle(): string` — Title of the current page
|
||||||
- `getCurrentPagePath(): string` - Path of current page
|
- `getCurrentPagePath(): string` — Path of the current page
|
||||||
- `isHomepage(): bool` - Whether current page is the homepage
|
- `isHomepage(): bool` — Whether the current page is the homepage
|
||||||
|
|
||||||
### Menu & Navigation
|
### Menu & navigation
|
||||||
|
|
||||||
- `getMenu(): array` - Hierarchical menu structure with `title`, `path`, `children`, `active`
|
- `getMenu(): array` — Hierarchical menu structure with `title`, `path`, `children`, `active`
|
||||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Build a URL for a page
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Build a URL
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- `getConfig(string $key, mixed $default = null): mixed` - Config value via dot notation (e.g. `'features.search_enabled'`)
|
- `getConfig(string $key, mixed $default = null): mixed` — Config value via dot notation (e.g. `'features.search_enabled'`)
|
||||||
- `getSiteTitle(): string` - Site title from config
|
- `getSiteTitle(): string` — Site title from config
|
||||||
|
|
||||||
### Language
|
### Language
|
||||||
|
|
||||||
- `getCurrentLanguage(): string` - Current language code (e.g. `'nl'`)
|
- `getCurrentLanguage(): string` — Current language code (e.g. `'nl'`)
|
||||||
- `getAvailableLanguages(): array` - Available languages (e.g. `['nl', 'en']`)
|
- `getAvailableLanguages(): array` — Available languages
|
||||||
- `t(string $key): string` - Translate a language key
|
- `t(string $key): string` — Translate a language key
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
- `getSearchResults(): array` - Search results (empty if not searching)
|
- `getSearchResults(): array` — Search results (empty if not searching)
|
||||||
- `isSearching(): bool` - Whether a search is currently active
|
- `isSearching(): bool` — Whether a search is currently active
|
||||||
|
|
||||||
## Example: Show recent pages
|
### Author
|
||||||
|
|
||||||
|
- `getPageAuthor(): array` — Author metadata from frontmatter (`author_name`, `author_email`, `created`)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Show recent pages
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
$pages = $api->getAllPages();
|
$entries = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
<?php foreach ($entries as $entry): ?>
|
||||||
|
<?php if ($entry['type'] === 'folder') continue; // skip folders ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||||
<?= htmlspecialchars($title) ?>
|
<?= htmlspecialchars($entry['title']) ?>
|
||||||
|
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example: Using config values
|
### Use a config value
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||||
@@ -89,6 +137,44 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dynamic greeting based on language
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$lang = $api->getCurrentLanguage();
|
||||||
|
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||||
|
?>
|
||||||
|
<h1><?= $greeting ?> to <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Choosing a layout
|
||||||
|
|
||||||
|
The frontmatter `layout` key determines which Twig template surrounds your
|
||||||
|
content. Available layouts are defined in `themes/<active-theme>/theme.json`
|
||||||
|
under the `template` section. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
layout: sidebar-content
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
If you omit a layout, `config.default_template` is used, falling back to
|
||||||
|
`full_content`. The content always lands at the `{{ content }}` spot in that
|
||||||
|
template.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Always use `htmlspecialchars()` for output of user-content or API data.
|
||||||
|
- PHP content files have access to the server filesystem — be careful with
|
||||||
|
`include`, `require` or `file_get_contents` on external paths. Stay inside
|
||||||
|
the `content/` directory.
|
||||||
|
- The ContentAPI is read-only; you cannot modify files or config through it.
|
||||||
|
|
||||||
## CMSAPI (for plugins)
|
## CMSAPI (for plugins)
|
||||||
|
|
||||||
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
|
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
|
||||||
@@ -108,12 +194,12 @@ $this->api->createUrl('about-us');
|
|||||||
$this->api->translate('home');
|
$this->api->translate('home');
|
||||||
```
|
```
|
||||||
|
|
||||||
Additionally, CMSAPI provides:
|
In addition, CMSAPI has:
|
||||||
|
|
||||||
- `getCurrentPage(): array` - Full page data
|
- `getCurrentPage(): array` — Full page data
|
||||||
- `getCurrentPageUrl(): string` - URL of current page
|
- `getCurrentPageUrl(): string` — URL of the current page
|
||||||
- `getCurrentPageFileInfo(): ?array` - File info (created, modified)
|
- `getCurrentPageFileInfo(): ?array` — File info (created, modified)
|
||||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||||
- `executePhpFile(string $filePath): string` - Execute PHP file and capture output
|
- `executePhpFile(string $filePath): string` — Execute a PHP file and capture output
|
||||||
- `getFileContent(string $filePath): string` - Get content from PHP/HTML/Markdown file
|
- `getFileContent(string $filePath): string` — Get content from a PHP/HTML/Markdown file
|
||||||
- `contentFileExists(string $filename): bool` - Check if file exists in content directory
|
- `contentFileExists(string $filename): bool` — Check whether a file exists in the content directory
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
# Content beheer
|
# Content beheer
|
||||||
|
|
||||||
CodePress biedt twee weergaven voor content-beheer:
|
CodePress gebruikt een **boom weergave** (`/admin/content`) voor content-beheer: een bestandsbrowser zijbalk + CodeMirror editor, uniform met de plugin- en thema-editors. De oude lijst/tabel weergave is verwijderd.
|
||||||
|
|
||||||
1. **Boom weergave** (`/admin/content`) — standaard, bestandsbrowser zijbalk + CodeMirror editor (uniform met plugin/theme-editors)
|
## Content editor
|
||||||
2. **Lijst weergave** (`/admin/content-list`) — klassieke tabel met filter, upload, nieuwe map/bestand per map
|
|
||||||
|
|
||||||
Beide werken naast elkaar en gebruiken dezelfde content-map. Wissel tussen de weergaven via de "Lijst weergave"/"Boom weergave" knoppen rechtsboven. De boom weergave biedt dezelfde functionaliteit als de lijst weergave, maar dan met een geneste bestandsboom zijbalk zoals de plugin- en thema-editors — voor consistente editor-ervaring.
|
|
||||||
|
|
||||||
## Boom weergave (content)
|
|
||||||
|
|
||||||
### Bestandsbrowser zijbalk
|
### Bestandsbrowser zijbalk
|
||||||
- Toont de geneste bestandsboom van `content/`
|
- Toont de geneste bestandsboom van `content/`
|
||||||
@@ -19,6 +14,30 @@ Beide werken naast elkaar en gebruiken dezelfde content-map. Wissel tussen de we
|
|||||||
### Bewerkbare bestandstypen
|
### Bewerkbare bestandstypen
|
||||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
|
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
|
||||||
|
|
||||||
|
Alle types ondersteunen `---` frontmatter (layout, created, edited, plugins). De CMS `parseMetadata()` haalt de frontmatter eruit vóór verwerking. Bij PHP bestanden wordt de frontmatter uit de output verwijderd.
|
||||||
|
|
||||||
|
#### PHP content bestanden
|
||||||
|
PHP bestanden kunnen op twee manieren content leveren:
|
||||||
|
1. **Echo** (output buffering): alles wat `echo`d of outside `<?php` staat wordt als content gebruikt
|
||||||
|
2. **Return** (callback stijl): `return '<h1>Hallo</h1>';` — de geretourneerde string wordt als content gebruikt
|
||||||
|
|
||||||
|
In PHP bestanden is de `ContentAPI` beschikbaar via `$api`:
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$menu = $api->getMenu();
|
||||||
|
$pages = $api->getAllPages();
|
||||||
|
$config = $api->getConfig('site_title');
|
||||||
|
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend URL's en bestandsnamen
|
||||||
|
De CMS zoekt bestanden in volgorde: `.md` → `.php` → `.html`. Als bestanden dezelfde naam hebben (bijv. `test.md` en `test.php`), opent `/nl/test` altijd `test.md`. Om een specifiek bestandstype te openen, voeg de extensie toe aan de URL:
|
||||||
|
- `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
|
||||||
|
- `/nl/test.php` → opent `test.php` (expliciete extensie)
|
||||||
|
- `/nl/test.html` → opent `test.html` (expliciete extensie)
|
||||||
|
|
||||||
|
De preview link in de editor gebruikt automatisch de extensie voor niet-`.md` bestanden.
|
||||||
|
|
||||||
### Image-grootte instellen (Markdown)
|
### Image-grootte instellen (Markdown)
|
||||||
In de markdown editor-toolbar staat een "Afbeelding grootte" knop (expand-icoon):
|
In de markdown editor-toolbar staat een "Afbeelding grootte" knop (expand-icoon):
|
||||||
1. Selecteer een afbeelding in de editor in markdown formaat ``
|
1. Selecteer een afbeelding in de editor in markdown formaat ``
|
||||||
@@ -41,7 +60,7 @@ Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staa
|
|||||||
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
|
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
|
||||||
- Kies het bestandstype (Markdown/PHP/HTML)
|
- Kies het bestandstype (Markdown/PHP/HTML)
|
||||||
- Submappen worden automatisch aangemaakt
|
- Submappen worden automatisch aangemaakt
|
||||||
- Frontmatter met `layout`, `author_name`, `author_email`, `created` wordt automatisch gegenereerd
|
- Frontmatter met `layout`, `created`, `edited` wordt automatisch gegenereerd
|
||||||
|
|
||||||
### Bestand uploaden
|
### Bestand uploaden
|
||||||
- Klik op **Upload** om bestanden naar `content/` te uploaden
|
- Klik op **Upload** om bestanden naar `content/` te uploaden
|
||||||
@@ -62,27 +81,22 @@ Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staa
|
|||||||
Op de editor-pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key.
|
Op de editor-pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key.
|
||||||
|
|
||||||
### Plugins op pagina's
|
### Plugins op pagina's
|
||||||
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie
|
- Alleen actieve **content plugins** (uit `plugin.json` met `type: "content"`) verschijnen in de plugin multiselect; system plugins (zoals Statistics/Logs/Dashboard) zijn uitgesloten
|
||||||
- Kies welke plugins op de pagina draaien
|
- Kies via de multiselect welke plugins op de pagina draaien (gebruik Ctrl/Cmd+klik voor meerdere)
|
||||||
- De plugin selectie wordt opgeslagen in de frontmatter `plugins:` key
|
- De plugin selectie wordt opgeslagen in de frontmatter `plugins:` key
|
||||||
|
|
||||||
### Git / Backup integratie (Fase 5)
|
### Backup integratie
|
||||||
Bovenaan de content-editor staan backup- en git-acties:
|
Bovenaan de content-editor staat een **Backup** knop die naar de backup-pagina (`/admin/content-backup`) leidt voor ZIP backup/restore en (indien beschikbaar) git versiebeheer. Git integratie in de editor-zijbalk is verwijderd; git wordt later een systeem plugin.
|
||||||
- **Git init**: initialiseert een git repository in `content/` (als er nog geen is)
|
|
||||||
- **Commit**: committed alle niet-committed wijzigingen (alleen als er een git repo is en er wijzigingen zijn)
|
|
||||||
- **Backup**: link naar de backup-pagina (`/admin/content-backup`) voor ZIP backup/restore
|
|
||||||
- De git status badge toont de huidige branch, of er niet-committed wijzigingen zijn, en de laatste commit
|
|
||||||
|
|
||||||
### Frontmatter
|
### Frontmatter
|
||||||
|
|
||||||
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
|
De editor toont de **Aangemaakt** en **Bewerkt** timestamps (read-only, automatisch bijgewerkt bij elke opslag). De `edited:` waarde wordt bij elke save vernieuwd.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
author_name: Admin
|
|
||||||
author_email: admin@example.com
|
|
||||||
created: 2026-08-19 10:30:25
|
created: 2026-08-19 10:30:25
|
||||||
|
edited: 2026-08-20 14:22:01
|
||||||
plugins: HTMLBlock, Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,24 +105,6 @@ plugins: HTMLBlock, Navigation
|
|||||||
Content...
|
Content...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lijst weergave (content)
|
|
||||||
|
|
||||||
### Bestanden beheren
|
|
||||||
|
|
||||||
- **Nieuwe map** — Mappen structuur aanmaken
|
|
||||||
- **Nieuw bestand** — Pagina aanmaken (`.md`, `.php`, `.html`)
|
|
||||||
- **Bewerken** — Bestaande content wijzigen in CodeMirror editor
|
|
||||||
- **Hernoemen** — Bestands- of mapnamen aanpassen
|
|
||||||
- **Verplaatsen** — Content verplaatsen naar andere map
|
|
||||||
- **Verwijderen** — Content verwijderen
|
|
||||||
- **Map hernoemen** — Map naam wijzigen
|
|
||||||
|
|
||||||
### Editor (content-edit)
|
|
||||||
|
|
||||||
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML)
|
|
||||||
- **Toolbar** voor snel Markdown invoeren
|
|
||||||
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
|
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
|
||||||
@@ -116,9 +112,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
plugins:
|
created: 2026-08-19 10:30:25
|
||||||
- HTMLBlock
|
edited: 2026-08-20 14:22:01
|
||||||
- Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pagina titel
|
# Pagina titel
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor
|
|||||||
|
|
||||||
### Media invoegen in editor
|
### Media invoegen in editor
|
||||||
- De media-knop in de editor-toolbar opent de media-modal
|
- De media-knop in de editor-toolbar opent de media-modal
|
||||||
|
- Media wordt getoond als een collapsible **bestandsboom** (folders inklappen/uitklappen), gelijk aan de content/plugin/theme editors
|
||||||
- In thema-context scant deze `themes/<naam>/assets/` (via `/admin/media-list?theme=<naam>`)
|
- In thema-context scant deze `themes/<naam>/assets/` (via `/admin/media-list?theme=<naam>`)
|
||||||
- Snippet-formaat depends op bestandstype: markdown → ``, html/php → `<img src=...>`, andere → ruwe URL
|
- Snippet-formaat depends op bestandstype: markdown → ``, html/php → `<img src=...>`, andere → ruwe URL
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,132 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
De Content API is beschikbaar in PHP content bestanden (`.php`) en biedt een veilige, read-only interface tot CMS data.
|
PHP content bestanden (`.php`) draaien binnen het CMS en hebben toegang tot een
|
||||||
|
veilige, read-only API via de variabele `$api`. De output van zo'n bestand wordt
|
||||||
|
automatisch in de actieve Twig layout geplaatst op de plek van `{{ content }}`.
|
||||||
|
|
||||||
## Gebruik in PHP content bestanden
|
## Hoe je content doorgeeft aan de Twig template
|
||||||
|
|
||||||
|
Er zijn **twee manieren** om content vanuit een `.php` bestand naar het Twig
|
||||||
|
template te sturen. De CMS kiest automatisch de juiste:
|
||||||
|
|
||||||
|
1. **Echo / print** — alles wat je echoot (of wat buiten `<?php ?>` tags staat)
|
||||||
|
wordt opgevangen en als `{{ content }}` in de layout geplaatst.
|
||||||
|
2. **Return** — als je `return` gebruikt met een string, wordt die string als
|
||||||
|
`{{ content }}` gebruikt (echt output wordt dan genegeerd).
|
||||||
|
|
||||||
|
Voorbeeld met echo:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
|
?>
|
||||||
// Alle pagina's ophalen
|
<h1>Hallo <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
$allPages = $api->getAllPages();
|
<p>Welkom op mijn pagina.</p>
|
||||||
// Resultaat: ['index' => 'Home', 'over-ons' => 'Over ons', ...]
|
|
||||||
|
|
||||||
// Specifieke pagina ophalen
|
|
||||||
$page = $api->getPage('over-ons');
|
|
||||||
// Resultaat: ['title' => 'Over ons', 'content' => '...', 'path' => 'over-ons', 'layout' => 'full_content', 'metadata' => [...]]
|
|
||||||
|
|
||||||
// Menu structuur ophalen
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
// Resultaat: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
|
||||||
|
|
||||||
// Config waarde ophalen (dot notatie)
|
|
||||||
$siteTitle = $api->getConfig('site_title');
|
|
||||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beschikbare methods
|
Voorbeeld met return:
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
return '<h1>Hallo ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
> De frontmatter (`---` blok) wordt door het CMS gestript vóórdat de PHP code
|
||||||
|
> uitgevoerd wordt. De layout key bepaalt welke Twig template de content omringt.
|
||||||
|
|
||||||
|
## Wat kun je níét doen
|
||||||
|
|
||||||
|
- Je kunt **geen** eigen Twig variabelen zetten vanuit een `.php` bestand.
|
||||||
|
PHP content leverde alleen de `{{ content }}` placeholder. Andere template
|
||||||
|
variabelen (`menu`, `breadcrumb`, `page_title`, etc.) worden door het CMS
|
||||||
|
vastgesteld op basis van de frontmatter en config — niet door je PHP code.
|
||||||
|
- Je kunt **geen** van buitenaf een ContentAPI aanroepen; de class wordt alleen
|
||||||
|
binnen `parsePHP()` geïnstantieerd en is nooit via een URL bereikbaar.
|
||||||
|
|
||||||
|
## Beschikbare variabelen in je PHP bestand
|
||||||
|
|
||||||
|
Binnen een `.php` content bestand zijn de volgende variabelen beschikbaar:
|
||||||
|
|
||||||
|
| Variabele | Type | Omschrijving |
|
||||||
|
|-----------|------|--------------|
|
||||||
|
| `$api` | `ContentAPI` | Read-only toegang tot CMS data |
|
||||||
|
| `$pageMetadata` | `array` | De frontmatter metadata van dit bestand |
|
||||||
|
|
||||||
|
## ContentAPI methods
|
||||||
|
|
||||||
### Pagina's
|
### Pagina's
|
||||||
|
|
||||||
- `getAllPages(): array` - Alle pagina's als `['pad' => 'titel']` pairs
|
- `getAllPages(): array` — Alle content entries (mappen + bestanden) als een list van `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||||
- `getPage(string $path): ?array` - Specifieke pagina met `title`, `content`, `path`, `layout`, `metadata`
|
- `getPage(string $path): ?array` — Specifieke pagina; levert `title`, `content`, `path`, `layout`, `metadata`. `$path` mag een extensie bevatten.
|
||||||
- `pageExists(string $path): bool` - Controleer of een pagina bestaat
|
- `pageExists(string $path): bool` — Controleer of een pagina bestaat
|
||||||
- `getCurrentPageTitle(): string` - Titel van huidige pagina
|
- `getCurrentPageTitle(): string` — Titel van de huidige pagina
|
||||||
- `getCurrentPagePath(): string` - Pad van huidige pagina
|
- `getCurrentPagePath(): string` — Pad van de huidige pagina
|
||||||
- `isHomepage(): bool` - Of huidige pagina de homepage is
|
- `isHomepage(): bool` — Of de huidige pagina de homepage is
|
||||||
|
|
||||||
### Menu & Navigatie
|
### Menu & navigatie
|
||||||
|
|
||||||
- `getMenu(): array` - Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
- `getMenu(): array` — Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
||||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Bouw een URL voor een pagina
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Bouw een URL
|
||||||
|
|
||||||
### Configuratie
|
### Configuratie
|
||||||
|
|
||||||
- `getConfig(string $key, mixed $default = null): mixed` - Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
- `getConfig(string $key, mixed $default = null): mixed` — Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
||||||
- `getSiteTitle(): string` - Site titel uit config
|
- `getSiteTitle(): string` — Site titel uit config
|
||||||
|
|
||||||
### Taal
|
### Taal
|
||||||
|
|
||||||
- `getCurrentLanguage(): string` - Huidige taal code (bijv. `'nl'`)
|
- `getCurrentLanguage(): string` — Huidige taal code (bijv. `'nl'`)
|
||||||
- `getAvailableLanguages(): array` - Beschikbare talen (bijv. `['nl', 'en']`)
|
- `getAvailableLanguages(): array` — Beschikbare talen
|
||||||
- `t(string $key): string` - Vertaal een language key
|
- `t(string $key): string` — Vertaal een language key
|
||||||
|
|
||||||
### Zoeken
|
### Zoeken
|
||||||
|
|
||||||
- `getSearchResults(): array` - Zoekresultaten (leeg als niet aan het zoeken)
|
- `getSearchResults(): array` — Zoekresultaten (leeg als niet aan het zoeken)
|
||||||
- `isSearching(): bool` - Of er momenteel gezocht wordt
|
- `isSearching(): bool` — Of er momenteel gezocht wordt
|
||||||
|
|
||||||
## Voorbeeld: Recentste pagina's tonen
|
### Auteur
|
||||||
|
|
||||||
|
- `getPageAuthor(): array` — Auteur metadata uit frontmatter (`author_name`, `author_email`, `created`)
|
||||||
|
|
||||||
|
## Voorbeelden
|
||||||
|
|
||||||
|
### Recente pagina's tonen
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
$pages = $api->getAllPages();
|
$entries = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
<?php foreach ($entries as $entry): ?>
|
||||||
|
<?php if ($entry['type'] === 'folder') continue; // sla mappen over ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||||
<?= htmlspecialchars($title) ?>
|
<?= htmlspecialchars($entry['title']) ?>
|
||||||
|
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Voorbeeld: Config waarde gebruiken
|
### Config waarde gebruiken
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||||
@@ -89,9 +137,48 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dynamische begroeting op basis van taal
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$lang = $api->getCurrentLanguage();
|
||||||
|
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||||
|
?>
|
||||||
|
<h1><?= $greeting ?> op <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout kiezen
|
||||||
|
|
||||||
|
De frontmatter `layout` key bepaalt welke Twig template je content omringt. De
|
||||||
|
beschikbare layouts staan in `themes/<actief-thema>/theme.json` onder de
|
||||||
|
`template` sectie. Bijvoorbeeld:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
layout: sidebar-content
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Als je geen layout opgeeft, wordt `config.default_template` gebruikt, met als
|
||||||
|
fallback `full_content`. De content komt altijd terecht op de `{{ content }}`
|
||||||
|
plek in die template.
|
||||||
|
|
||||||
|
## Veiligheid
|
||||||
|
|
||||||
|
- Gebruik altijd `htmlspecialchars()` voor output van user-content of API data.
|
||||||
|
- PHP content bestanden hebben toegang tot het bestandssysteem van de server —
|
||||||
|
wees voorzichtig met `include`, `require` of `file_get_contents` op externe
|
||||||
|
paden. Blijf binnen de `content/` map.
|
||||||
|
- De ContentAPI is read-only; je kunt er geen bestanden of config mee wijzigen.
|
||||||
|
|
||||||
## CMSAPI (voor plugins)
|
## CMSAPI (voor plugins)
|
||||||
|
|
||||||
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare methods:
|
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare
|
||||||
|
methods:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$this->api->getCurrentPageTitle();
|
$this->api->getCurrentPageTitle();
|
||||||
@@ -110,10 +197,10 @@ $this->api->translate('home');
|
|||||||
|
|
||||||
Daarnaast heeft de CMSAPI:
|
Daarnaast heeft de CMSAPI:
|
||||||
|
|
||||||
- `getCurrentPage(): array` - Volledige pagina data
|
- `getCurrentPage(): array` — Volledige pagina data
|
||||||
- `getCurrentPageUrl(): string` - URL van huidige pagina
|
- `getCurrentPageUrl(): string` — URL van huidige pagina
|
||||||
- `getCurrentPageFileInfo(): ?array` - Bestandsinfo (created, modified)
|
- `getCurrentPageFileInfo(): ?array` — Bestandsinfo (created, modified)
|
||||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||||
- `executePhpFile(string $filePath): string` - Voer PHP bestand uit en vang output op
|
- `executePhpFile(string $filePath): string` — Voer PHP bestand uit en vang output op
|
||||||
- `getFileContent(string $filePath): string` - Haal content uit PHP/HTML/Markdown bestand
|
- `getFileContent(string $filePath): string` — Haal content uit PHP/HTML/Markdown bestand
|
||||||
- `contentFileExists(string $filename): bool` - Controleer of bestand bestaat in content map
|
- `contentFileExists(string $filename): bool` — Controleer of bestand bestaat in content map
|
||||||
@@ -128,6 +128,8 @@ return [
|
|||||||
'visible_plugins' => 'Sichtbare Plugins',
|
'visible_plugins' => 'Sichtbare Plugins',
|
||||||
'new_page' => 'Neue Seite',
|
'new_page' => 'Neue Seite',
|
||||||
'file_type' => 'Dateityp',
|
'file_type' => 'Dateityp',
|
||||||
|
'created' => 'Erstellt',
|
||||||
|
'edited' => 'Bearbeitet',
|
||||||
|
|
||||||
// Content dir form
|
// Content dir form
|
||||||
'rename_folder' => 'Ordner umbenennen',
|
'rename_folder' => 'Ordner umbenennen',
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ return [
|
|||||||
'cannot_change_own_role' => 'You cannot change your own role.',
|
'cannot_change_own_role' => 'You cannot change your own role.',
|
||||||
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
|
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
|
||||||
'created' => 'Created',
|
'created' => 'Created',
|
||||||
|
'edited' => 'Edited',
|
||||||
|
|
||||||
// Plugin config
|
// Plugin config
|
||||||
'plugin_settings' => 'Settings',
|
'plugin_settings' => 'Settings',
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ return [
|
|||||||
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
|
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
|
||||||
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
|
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
|
||||||
'created' => 'Aangemaakt',
|
'created' => 'Aangemaakt',
|
||||||
|
'edited' => 'Bewerkt',
|
||||||
|
|
||||||
// Plugin config
|
// Plugin config
|
||||||
'plugin_settings' => 'Instellingen',
|
'plugin_settings' => 'Instellingen',
|
||||||
|
|||||||
+195
-100
@@ -159,6 +159,59 @@ function isProtectedPlugin(string $pluginName): bool {
|
|||||||
return in_array($pluginName, getProtectedPlugins(), true);
|
return in_array($pluginName, getProtectedPlugins(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of content-type plugins (name => [name, title, type]).
|
||||||
|
* Used by the content editor "visible plugins" selector so only content
|
||||||
|
* plugins (not system plugins like Statistics/Logs/Dashboard) are offered.
|
||||||
|
*
|
||||||
|
* Type resolution mirrors handlePlugins:
|
||||||
|
* 1. default 'content'
|
||||||
|
* 2. plugin.json 'type' override
|
||||||
|
* 3. regex from <Plugin>.php 'type' => '...' (fallback when plugin.json has no type)
|
||||||
|
*
|
||||||
|
* @param string $pluginsDir Absolute path to the plugins directory
|
||||||
|
* @return array<int,array{name:string,title:string,type:string}> Content-type plugins only
|
||||||
|
*/
|
||||||
|
function getContentPlugins(string $pluginsDir): array
|
||||||
|
{
|
||||||
|
$plugins = [];
|
||||||
|
if (!is_dir($pluginsDir)) {
|
||||||
|
return $plugins;
|
||||||
|
}
|
||||||
|
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
|
||||||
|
$pluginName = basename($pluginDir);
|
||||||
|
if ($pluginName === '.' || $pluginName === '..') continue;
|
||||||
|
$pluginJson = $pluginDir . '/plugin.json';
|
||||||
|
|
||||||
|
$type = 'content';
|
||||||
|
$title = ucfirst($pluginName);
|
||||||
|
|
||||||
|
if (file_exists($pluginJson)) {
|
||||||
|
$data = json_decode(file_get_contents($pluginJson), true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
if (isset($data['type'])) $type = $data['type'];
|
||||||
|
if (isset($data['name'])) $title = $data['name'];
|
||||||
|
elseif (isset($data['title'])) $title = $data['title'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($type === 'content') {
|
||||||
|
// Fallback: detect type from plugin PHP when plugin.json has no explicit type
|
||||||
|
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
|
||||||
|
if (file_exists($pluginJson) && !isset($data['type']) && file_exists($pluginFile)) {
|
||||||
|
$source = file_get_contents($pluginFile);
|
||||||
|
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $source, $m)) {
|
||||||
|
$type = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($type === 'content') {
|
||||||
|
$plugins[] = ['name' => $pluginName, 'title' => $title, 'type' => $type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($plugins, function ($a, $b) { return strcasecmp($a['name'], $b['name']); });
|
||||||
|
return $plugins;
|
||||||
|
}
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Public routes (no auth required)
|
||||||
if ($route === 'login') {
|
if ($route === 'login') {
|
||||||
$error = '';
|
$error = '';
|
||||||
@@ -274,8 +327,9 @@ switch ($route) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'content-list':
|
case 'content-list':
|
||||||
handleContent($auth, $appConfig, $twig, $user, $csrf, $siteConfig);
|
// Lijst weergave is verwijderd; redirect naar de boom-editor
|
||||||
break;
|
header('Location: /admin/content');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'content-files':
|
case 'content-files':
|
||||||
// Backward compat: redirect to the unified content editor
|
// Backward compat: redirect to the unified content editor
|
||||||
@@ -737,13 +791,21 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
} else {
|
} else {
|
||||||
$newReal = $parentReal . '/' . basename($newFullPath);
|
$newReal = $parentReal . '/' . basename($newFullPath);
|
||||||
if (file_exists($newReal)) {
|
if (file_exists($newReal)) {
|
||||||
$message = 'Bestand bestaat al.';
|
$message = 'Bestand met dit type bestaat al.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
} else {
|
} else {
|
||||||
// Build frontmatter with author metadata (zoals handleContentNew)
|
// Build frontmatter with layout/created/edited timestamps.
|
||||||
|
// All editable types (md/php/html) support --- frontmatter; the CMS
|
||||||
|
// parseMetadata() extracts it and parsePHP() strips it from output.
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$frontmatter = "---\nlayout: full_content\nauthor_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\nauthor_email: " . ($user['author_email'] ?? '') . "\ncreated: " . $now . "\n---\n\n";
|
$frontmatter = "---\nlayout: full_content\ncreated: " . $now . "\nedited: " . $now . "\n---\n\n";
|
||||||
$stub = $ext === 'md' ? $frontmatter . "# Nieuwe pagina\n\n" : ($ext === 'php' ? "<?php\n" : "<h1>Nieuwe pagina</h1>\n");
|
if ($ext === 'md') {
|
||||||
|
$stub = $frontmatter . "# Nieuwe pagina\n\n";
|
||||||
|
} elseif ($ext === 'php') {
|
||||||
|
$stub = $frontmatter . "<?php\n";
|
||||||
|
} else {
|
||||||
|
$stub = $frontmatter . "<h1>Nieuwe pagina</h1>\n";
|
||||||
|
}
|
||||||
$written = @file_put_contents($newReal, $stub);
|
$written = @file_put_contents($newReal, $stub);
|
||||||
if ($written !== false) {
|
if ($written !== false) {
|
||||||
adminLog($config, 'info', $user['username'] . ' creëerde content bestand ' . $newRel);
|
adminLog($config, 'info', $user['username'] . ' creëerde content bestand ' . $newRel);
|
||||||
@@ -870,23 +932,57 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
// Save the currently selected file
|
// Save the currently selected file
|
||||||
$content = $_POST['content'] ?? '';
|
$content = $_POST['content'] ?? '';
|
||||||
$layout = $_POST['layout'] ?? '';
|
$layout = $_POST['layout'] ?? '';
|
||||||
|
$newFilename = trim($_POST['new_filename'] ?? '');
|
||||||
if ($realPath && is_file($realPath)) {
|
if ($realPath && is_file($realPath)) {
|
||||||
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
||||||
if (in_array($ext, $editableExts, true)) {
|
if (in_array($ext, $editableExts, true)) {
|
||||||
if ($layout && $ext === 'md') {
|
if ($layout) {
|
||||||
$content = updateContentFrontmatter($content, 'layout', $layout);
|
$content = updateContentFrontmatter($content, 'layout', $layout);
|
||||||
}
|
}
|
||||||
$plugins = isset($_POST['plugins']) && is_array($_POST['plugins'])
|
$plugins = isset($_POST['plugins']) && is_array($_POST['plugins'])
|
||||||
? implode(', ', array_map('trim', $_POST['plugins']))
|
? implode(', ', array_map('trim', $_POST['plugins']))
|
||||||
: '';
|
: '';
|
||||||
if ($ext === 'md') {
|
|
||||||
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
||||||
}
|
// Stamp the last edit time so the editor can show created/edited
|
||||||
|
$content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s'));
|
||||||
|
|
||||||
|
// Rename if the filename changed
|
||||||
|
if ($newFilename !== '') {
|
||||||
|
$cleanName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
|
||||||
|
$cleanName = preg_replace('/\.(md|php|html)$/i', '', $cleanName);
|
||||||
|
$newFull = dirname($realPath) . '/' . $cleanName . '.' . $ext;
|
||||||
|
if ($newFull !== $realPath) {
|
||||||
|
if (file_exists($newFull)) {
|
||||||
|
$message = 'Bestandsnaam bestaat al.';
|
||||||
|
$messageType = 'danger';
|
||||||
|
} else {
|
||||||
backupContentFile($realPath);
|
backupContentFile($realPath);
|
||||||
file_put_contents($realPath, $content);
|
file_put_contents($realPath, $content);
|
||||||
|
rename($realPath, $newFull);
|
||||||
|
$realPath = $newFull;
|
||||||
|
$relFile = trim(dirname($relFile), '.\\/') ? trim(dirname($relFile), '.\\/') . '/' . $cleanName . '.' . $ext : $cleanName . '.' . $ext;
|
||||||
|
$renamedTo = $relFile;
|
||||||
|
adminLog($config, 'info', $user['username'] . ' hernoemde content naar ' . $relFile);
|
||||||
|
$message = 'Bestand opgeslagen en hernoemd.';
|
||||||
|
$messageType = 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($message !== 'Bestandsnaam bestaat al.') {
|
||||||
|
backupContentFile($realPath);
|
||||||
|
file_put_contents($realPath, $content);
|
||||||
|
if ($message === '') {
|
||||||
adminLog($config, 'info', $user['username'] . ' bewerkte content ' . $relFile);
|
adminLog($config, 'info', $user['username'] . ' bewerkte content ' . $relFile);
|
||||||
$message = 'Bestand opgeslagen.';
|
$message = 'Bestand opgeslagen.';
|
||||||
$messageType = 'success';
|
$messageType = 'success';
|
||||||
|
}
|
||||||
|
// If renamed, redirect to the new URL so the browser URL stays in sync
|
||||||
|
if (isset($renamedTo)) {
|
||||||
|
header('Location: /admin/content?file=' . urlencode($renamedTo) . '&msg=' . urlencode($message) . '&msgtype=success');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$message = 'Dit bestandstype kan niet bewerkt worden.';
|
$message = 'Dit bestandstype kan niet bewerkt worden.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
@@ -917,9 +1013,11 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
$fileContent = '';
|
$fileContent = '';
|
||||||
$fileExt = '';
|
$fileExt = '';
|
||||||
$fileName = '';
|
$fileName = '';
|
||||||
|
$fileBaseName = '';
|
||||||
if ($realPath && is_file($realPath)) {
|
if ($realPath && is_file($realPath)) {
|
||||||
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
|
||||||
$fileName = basename($realPath);
|
$fileName = basename($realPath);
|
||||||
|
$fileBaseName = basename($realPath, '.' . $fileExt);
|
||||||
if (in_array($fileExt, $editableExts, true)) {
|
if (in_array($fileExt, $editableExts, true)) {
|
||||||
$isEditable = true;
|
$isEditable = true;
|
||||||
$fileContent = file_get_contents($realPath);
|
$fileContent = file_get_contents($realPath);
|
||||||
@@ -943,15 +1041,17 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available plugins for the plugins checkbox group
|
// Available content plugins for the plugins multiselect (only active
|
||||||
|
// content-type plugins, never system plugins like Statistics/Logs).
|
||||||
$pluginsDir = $config['plugins_dir'];
|
$pluginsDir = $config['plugins_dir'];
|
||||||
|
$contentPlugins = getContentPlugins($pluginsDir);
|
||||||
|
// Keep only enabled plugins so the selector reflects what actually runs.
|
||||||
|
// enabled_plugins lives in config.json ($siteConfig), not in app.php.
|
||||||
|
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
|
||||||
$availablePlugins = [];
|
$availablePlugins = [];
|
||||||
if (is_dir($pluginsDir)) {
|
foreach ($contentPlugins as $p) {
|
||||||
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
|
if (in_array($p['name'], $enabledPlugins, true)) {
|
||||||
$pluginName = basename($pluginDir);
|
$availablePlugins[] = $p;
|
||||||
if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) {
|
|
||||||
$availablePlugins[] = $pluginName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,26 +1060,11 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
$currentPlugins = $isEditable ? extractFrontmatterValue($fileContent, 'plugins') : '';
|
$currentPlugins = $isEditable ? extractFrontmatterValue($fileContent, 'plugins') : '';
|
||||||
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
|
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
|
||||||
if ($selectedPlugins === ['']) $selectedPlugins = [];
|
if ($selectedPlugins === ['']) $selectedPlugins = [];
|
||||||
$currentAuthorName = $isEditable ? extractFrontmatterValue($fileContent, 'author_name') : '';
|
|
||||||
$currentAuthorEmail = $isEditable ? extractFrontmatterValue($fileContent, 'author_email') : '';
|
|
||||||
$currentCreated = $isEditable ? extractFrontmatterValue($fileContent, 'created') : '';
|
$currentCreated = $isEditable ? extractFrontmatterValue($fileContent, 'created') : '';
|
||||||
|
$currentEdited = $isEditable ? extractFrontmatterValue($fileContent, 'edited') : '';
|
||||||
$currentLang = $relFile ? extractLanguagePrefix($relFile) : 'nl';
|
$currentLang = $relFile ? extractLanguagePrefix($relFile) : 'nl';
|
||||||
$fileDir = $relFile ? trim(dirname($relFile), '.\\/') : '';
|
$fileDir = $relFile ? trim(dirname($relFile), '.\\/') : '';
|
||||||
|
|
||||||
// Git status for Fase 5 (content git integratie in zijbalk)
|
|
||||||
$gitStatus = ['available' => false, 'initialized' => false, 'dirty' => false, 'branch' => '', 'ahead' => 0, 'behind' => 0, 'lastCommit' => ''];
|
|
||||||
$gitDir = $realContentDir . '/.git';
|
|
||||||
$gitStatus['available'] = $is_dir = is_dir($gitDir);
|
|
||||||
if ($gitStatus['available']) {
|
|
||||||
$gitStatus['initialized'] = true;
|
|
||||||
$branch = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git rev-parse --abbrev-ref HEAD 2>/dev/null');
|
|
||||||
$gitStatus['branch'] = trim($branch ?? '');
|
|
||||||
$dirty = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git status --porcelain 2>/dev/null');
|
|
||||||
$gitStatus['dirty'] = trim($dirty ?? '') !== '';
|
|
||||||
$lastCommit = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git log -1 --format="%h %s (%cr)" 2>/dev/null');
|
|
||||||
$gitStatus['lastCommit'] = trim($lastCommit ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$route = 'content-files';
|
$route = 'content-files';
|
||||||
|
|
||||||
echo $twig->render('pages/content-files.twig', [
|
echo $twig->render('pages/content-files.twig', [
|
||||||
@@ -988,7 +1073,9 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
'csrf_token' => $csrf,
|
'csrf_token' => $csrf,
|
||||||
'relFile' => $relFile,
|
'relFile' => $relFile,
|
||||||
'fileDir' => $fileDir,
|
'fileDir' => $fileDir,
|
||||||
|
'selectedDir' => $fileDir,
|
||||||
'fileName' => $fileName,
|
'fileName' => $fileName,
|
||||||
|
'fileBaseName' => $fileBaseName,
|
||||||
'fileExt' => $fileExt,
|
'fileExt' => $fileExt,
|
||||||
'fileContent' => $fileContent,
|
'fileContent' => $fileContent,
|
||||||
'isEditable' => $isEditable,
|
'isEditable' => $isEditable,
|
||||||
@@ -1001,10 +1088,8 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
|
|||||||
'availablePlugins' => $availablePlugins,
|
'availablePlugins' => $availablePlugins,
|
||||||
'selectedPlugins' => $selectedPlugins,
|
'selectedPlugins' => $selectedPlugins,
|
||||||
'currentLang' => $currentLang,
|
'currentLang' => $currentLang,
|
||||||
'currentAuthorName' => $currentAuthorName,
|
|
||||||
'currentAuthorEmail' => $currentAuthorEmail,
|
|
||||||
'currentCreated' => $currentCreated,
|
'currentCreated' => $currentCreated,
|
||||||
'gitStatus' => $gitStatus,
|
'currentEdited' => $currentEdited,
|
||||||
'sidebar_color' => getSidebarColor($config),
|
'sidebar_color' => getSidebarColor($config),
|
||||||
'needs_editor' => $isEditable,
|
'needs_editor' => $isEditable,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
@@ -1232,6 +1317,16 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Optional rename: new filename (without extension, extension is preserved)
|
||||||
|
$newName = trim($_POST['new_name'] ?? '');
|
||||||
|
$ext = strtolower(pathinfo($relFile, PATHINFO_EXTENSION));
|
||||||
|
if ($newName !== '') {
|
||||||
|
$newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newName);
|
||||||
|
// Strip any extension the user may have typed (we preserve the original)
|
||||||
|
$newName = preg_replace('/\.(md|php|html)$/i', '', $newName);
|
||||||
|
} else {
|
||||||
|
$newName = basename($relFile, '.' . $ext);
|
||||||
|
}
|
||||||
if ($message === '') {
|
if ($message === '') {
|
||||||
$destDir = $dest === '' ? $realContentDir : $realContentDir . '/' . $dest;
|
$destDir = $dest === '' ? $realContentDir : $realContentDir . '/' . $dest;
|
||||||
$realDestDir = realpath($destDir);
|
$realDestDir = realpath($destDir);
|
||||||
@@ -1239,18 +1334,24 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
|
|||||||
$message = 'Doelmap bestaat niet.';
|
$message = 'Doelmap bestaat niet.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
} else {
|
} else {
|
||||||
$newPath = $realDestDir . '/' . basename($realPath);
|
$newFileName = $newName . '.' . $ext;
|
||||||
|
$newPath = $realDestDir . '/' . $newFileName;
|
||||||
|
if (strtolower($newPath) === strtolower($realPath)) {
|
||||||
|
// No change
|
||||||
|
header('Location: /admin/content?file=' . urlencode($relFile) . '&msg=' . urlencode('Geen wijziging.') . '&msgtype=info');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
if (file_exists($newPath)) {
|
if (file_exists($newPath)) {
|
||||||
$message = 'Bestand bestaat al op de bestemming.';
|
$message = 'Bestand bestaat al op de bestemming.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
} elseif (rename($realPath, $newPath)) {
|
} elseif (rename($realPath, $newPath)) {
|
||||||
adminLog($config, 'info', $user['username'] . ' verplaatste content/' . $relFile . ' naar content/' . ($dest ? $dest . '/' : '') . basename($relFile));
|
$newRel = $dest === '' ? $newFileName : $dest . '/' . $newFileName;
|
||||||
$newRel = $dest === '' ? basename($relFile) : $dest . '/' . basename($relFile);
|
adminLog($config, 'info', $user['username'] . ' hernoemde/verplaatste content/' . $relFile . ' naar content/' . $newRel);
|
||||||
header('Location: /admin/content?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand verplaatst.') . '&msgtype=success');
|
header('Location: /admin/content?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand hernoemd/verplaatst.') . '&msgtype=success');
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$err = error_get_last();
|
$err = error_get_last();
|
||||||
$message = 'Verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend');
|
$message = 'Hernoemen/verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend');
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1263,7 +1364,8 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
|
|||||||
'route' => 'content-file-move',
|
'route' => 'content-file-move',
|
||||||
'csrf_token' => $csrf,
|
'csrf_token' => $csrf,
|
||||||
'relFile' => $relFile,
|
'relFile' => $relFile,
|
||||||
'itemName' => basename($relFile),
|
'itemName' => basename($relFile, '.' . pathinfo($relFile, PATHINFO_EXTENSION)),
|
||||||
|
'itemExt' => '.' . pathinfo($relFile, PATHINFO_EXTENSION),
|
||||||
'itemDir' => trim(dirname($relFile), '.\\/'),
|
'itemDir' => trim(dirname($relFile), '.\\/'),
|
||||||
'directories' => $directories,
|
'directories' => $directories,
|
||||||
'sidebar_color' => getSidebarColor($config),
|
'sidebar_color' => getSidebarColor($config),
|
||||||
@@ -1475,7 +1577,7 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
$realPath = realpath($filePath);
|
$realPath = realpath($filePath);
|
||||||
$realContentDir = realpath($contentDir);
|
$realContentDir = realpath($contentDir);
|
||||||
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) {
|
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,28 +1589,6 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
$message = 'Ongeldige CSRF token.';
|
$message = 'Ongeldige CSRF token.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
} else {
|
} else {
|
||||||
// Handle rename
|
|
||||||
$newFilename = trim($_POST['filename'] ?? '');
|
|
||||||
$wasRenamed = false;
|
|
||||||
if (!empty($newFilename)) {
|
|
||||||
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
|
|
||||||
$newFilename .= '.' . $fileExt;
|
|
||||||
$parentDir = dirname($filePath);
|
|
||||||
$newFilePath = $parentDir . '/' . $newFilename;
|
|
||||||
|
|
||||||
$realParentDir = realpath($parentDir);
|
|
||||||
if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) {
|
|
||||||
if ($newFilePath !== $filePath && !file_exists($newFilePath)) {
|
|
||||||
rename($filePath, $newFilePath);
|
|
||||||
$wasRenamed = true;
|
|
||||||
adminLog($config, 'info', $user['username'] . ' hernoemde ' . basename($filePath) . ' naar ' . $newFilename);
|
|
||||||
$filePath = $newFilePath;
|
|
||||||
$newFile = dirname($file) . '/' . $newFilename;
|
|
||||||
$file = ltrim($newFile, './');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save content only for editable files
|
// Save content only for editable files
|
||||||
if ($isEditable) {
|
if ($isEditable) {
|
||||||
$content = $_POST['content'] ?? '';
|
$content = $_POST['content'] ?? '';
|
||||||
@@ -1520,11 +1600,10 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
? implode(', ', array_map('trim', $_POST['plugins']))
|
? implode(', ', array_map('trim', $_POST['plugins']))
|
||||||
: '';
|
: '';
|
||||||
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
||||||
|
$content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s'));
|
||||||
backupContentFile($filePath);
|
backupContentFile($filePath);
|
||||||
file_put_contents($filePath, $content);
|
file_put_contents($filePath, $content);
|
||||||
if (!$wasRenamed) {
|
|
||||||
adminLog($config, 'info', $user['username'] . ' bewerkte ' . basename($filePath));
|
adminLog($config, 'info', $user['username'] . ' bewerkte ' . basename($filePath));
|
||||||
}
|
|
||||||
$message = 'Bestand opgeslagen.';
|
$message = 'Bestand opgeslagen.';
|
||||||
$messageType = 'success';
|
$messageType = 'success';
|
||||||
}
|
}
|
||||||
@@ -1532,12 +1611,12 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
$fileName = basename($filePath);
|
$fileName = basename($filePath);
|
||||||
|
$fileBaseName = basename($filePath, '.' . $fileExt);
|
||||||
$fileContent = $isEditable ? file_get_contents($filePath) : '';
|
$fileContent = $isEditable ? file_get_contents($filePath) : '';
|
||||||
$currentLayout = extractFrontmatterValue($fileContent, 'layout');
|
$currentLayout = extractFrontmatterValue($fileContent, 'layout');
|
||||||
$currentPlugins = extractFrontmatterValue($fileContent, 'plugins');
|
$currentPlugins = extractFrontmatterValue($fileContent, 'plugins');
|
||||||
$currentAuthorName = extractFrontmatterValue($fileContent, 'author_name');
|
|
||||||
$currentAuthorEmail = extractFrontmatterValue($fileContent, 'author_email');
|
|
||||||
$currentCreated = extractFrontmatterValue($fileContent, 'created');
|
$currentCreated = extractFrontmatterValue($fileContent, 'created');
|
||||||
|
$currentEdited = extractFrontmatterValue($fileContent, 'edited');
|
||||||
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
|
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
|
||||||
if ($selectedPlugins === ['']) $selectedPlugins = [];
|
if ($selectedPlugins === ['']) $selectedPlugins = [];
|
||||||
|
|
||||||
@@ -1569,15 +1648,15 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get available plugins
|
// Get available content plugins (only enabled content-type plugins).
|
||||||
|
// enabled_plugins lives in config.json ($siteConfig), not in app.php.
|
||||||
$pluginsDir = $config['plugins_dir'];
|
$pluginsDir = $config['plugins_dir'];
|
||||||
|
$contentPlugins = getContentPlugins($pluginsDir);
|
||||||
|
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
|
||||||
$availablePlugins = [];
|
$availablePlugins = [];
|
||||||
if (is_dir($pluginsDir)) {
|
foreach ($contentPlugins as $p) {
|
||||||
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
|
if (in_array($p['name'], $enabledPlugins, true)) {
|
||||||
$pluginName = basename($pluginDir);
|
$availablePlugins[] = $p;
|
||||||
if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) {
|
|
||||||
$availablePlugins[] = $pluginName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,6 +1671,7 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
'file' => $file,
|
'file' => $file,
|
||||||
'fileDir' => $fileDir,
|
'fileDir' => $fileDir,
|
||||||
'fileName' => $fileName,
|
'fileName' => $fileName,
|
||||||
|
'fileBaseName' => $fileBaseName,
|
||||||
'fileExt' => $fileExt,
|
'fileExt' => $fileExt,
|
||||||
'fileContent' => $fileContent,
|
'fileContent' => $fileContent,
|
||||||
'isEditable' => $isEditable,
|
'isEditable' => $isEditable,
|
||||||
@@ -1602,9 +1682,8 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
|
|||||||
'availablePlugins' => $availablePlugins,
|
'availablePlugins' => $availablePlugins,
|
||||||
'selectedPlugins' => $selectedPlugins,
|
'selectedPlugins' => $selectedPlugins,
|
||||||
'currentLang' => $currentLang,
|
'currentLang' => $currentLang,
|
||||||
'currentAuthorName' => $currentAuthorName,
|
|
||||||
'currentAuthorEmail' => $currentAuthorEmail,
|
|
||||||
'currentCreated' => $currentCreated,
|
'currentCreated' => $currentCreated,
|
||||||
|
'currentEdited' => $currentEdited,
|
||||||
'sidebar_color' => getSidebarColor($config),
|
'sidebar_color' => getSidebarColor($config),
|
||||||
'needs_editor' => $isEditable,
|
'needs_editor' => $isEditable,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
@@ -1645,28 +1724,35 @@ function handleContentNew($auth, $config, $twig, $user, $csrf, $siteConfig): voi
|
|||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
} else {
|
} else {
|
||||||
$filename = trim($_POST['filename'] ?? '');
|
$filename = trim($_POST['filename'] ?? '');
|
||||||
$ext = $_POST['extension'] ?? 'md';
|
$ext = strtolower(trim($_POST['extension'] ?? 'md'));
|
||||||
|
if (!in_array($ext, ['md', 'php', 'html'], true)) $ext = 'md';
|
||||||
$layout = $_POST['layout'] ?? $themeDefaultLayout;
|
$layout = $_POST['layout'] ?? $themeDefaultLayout;
|
||||||
if (!empty($filename)) {
|
if (!empty($filename)) {
|
||||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename);
|
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename);
|
||||||
$filename .= '.' . $ext;
|
$filename .= '.' . $ext;
|
||||||
$dest = rtrim($fullPath, '/') . '/' . $filename;
|
$dest = rtrim($fullPath, '/') . '/' . $filename;
|
||||||
if (!file_exists($dest)) {
|
if (!file_exists($dest)) {
|
||||||
// Build frontmatter with layout and author metadata
|
// Build frontmatter with layout and created/edited timestamps.
|
||||||
|
// All editable types (md/php/html) support --- frontmatter.
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$frontmatter = "---\n";
|
$frontmatter = "---\n";
|
||||||
$frontmatter .= "layout: " . $layout . "\n";
|
$frontmatter .= "layout: " . $layout . "\n";
|
||||||
$frontmatter .= "author_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\n";
|
|
||||||
$frontmatter .= "author_email: " . ($user['author_email'] ?? '') . "\n";
|
|
||||||
$frontmatter .= "created: " . $now . "\n";
|
$frontmatter .= "created: " . $now . "\n";
|
||||||
|
$frontmatter .= "edited: " . $now . "\n";
|
||||||
$frontmatter .= "---\n\n";
|
$frontmatter .= "---\n\n";
|
||||||
|
if ($ext === 'php') {
|
||||||
|
$content = $frontmatter . "<?php\n";
|
||||||
|
} elseif ($ext === 'html') {
|
||||||
|
$content = $frontmatter . "<h1>Nieuwe pagina</h1>\n";
|
||||||
|
} else {
|
||||||
$content = $frontmatter . "# Nieuwe pagina\n\n";
|
$content = $frontmatter . "# Nieuwe pagina\n\n";
|
||||||
|
}
|
||||||
file_put_contents($dest, $content);
|
file_put_contents($dest, $content);
|
||||||
adminLog($config, 'info', $user['username'] . ' creëerde ' . $filename);
|
adminLog($config, 'info', $user['username'] . ' creëerde ' . $filename);
|
||||||
header('Location: /admin/content-edit?file=' . urlencode($dir . '/' . $filename));
|
header('Location: /admin/content-edit?file=' . urlencode($dir . '/' . $filename));
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$message = 'Bestand bestaat al.';
|
$message = 'Bestand met dit type bestaat al.';
|
||||||
$messageType = 'danger';
|
$messageType = 'danger';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1695,13 +1781,13 @@ function handleContentNew($auth, $config, $twig, $user, $csrf, $siteConfig): voi
|
|||||||
function handleContentDelete($auth, $config): void
|
function handleContentDelete($auth, $config): void
|
||||||
{
|
{
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1715,20 +1801,20 @@ function handleContentDelete($auth, $config): void
|
|||||||
adminLog($config, 'info', $user['username'] . ' verwijderde ' . $file);
|
adminLog($config, 'info', $user['username'] . ' verwijderde ' . $file);
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Location: /admin/content-list?dir=' . urlencode(dirname($file)));
|
header('Location: /admin/content?dir=' . urlencode(dirname($file)));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentDirCreate($auth, $config): void
|
function handleContentDirCreate($auth, $config): void
|
||||||
{
|
{
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1746,7 +1832,7 @@ function handleContentDirCreate($auth, $config): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Location: /admin/content-list?dir=' . urlencode($subdir));
|
header('Location: /admin/content?dir=' . urlencode($subdir));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1773,7 +1859,7 @@ function handleContentDirRename($auth, $config, $twig, $user, $csrf): void
|
|||||||
if (!file_exists($newPath) && $newPath !== $fullPath) {
|
if (!file_exists($newPath) && $newPath !== $fullPath) {
|
||||||
rename($fullPath, $newPath);
|
rename($fullPath, $newPath);
|
||||||
adminLog($config, 'info', $user['username'] . ' hernoemde map ' . basename($dir) . ' naar ' . $newName);
|
adminLog($config, 'info', $user['username'] . ' hernoemde map ' . basename($dir) . ' naar ' . $newName);
|
||||||
header('Location: /admin/content-list?dir=' . urlencode(dirname($dir) . '/' . $newName));
|
header('Location: /admin/content?dir=' . urlencode(dirname($dir) . '/' . $newName));
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$message = 'Map bestaat al of ongeldige naam.';
|
$message = 'Map bestaat al of ongeldige naam.';
|
||||||
@@ -1802,13 +1888,13 @@ function handleContentDirRename($auth, $config, $twig, $user, $csrf): void
|
|||||||
function handleContentDirDelete($auth, $config): void
|
function handleContentDirDelete($auth, $config): void
|
||||||
{
|
{
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $auth->getCurrentUser();
|
$user = $auth->getCurrentUser();
|
||||||
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
||||||
header('Location: /admin/content-list');
|
header('Location: /admin/content');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1826,7 +1912,7 @@ function handleContentDirDelete($auth, $config): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Location: /admin/content-list?dir=' . urlencode(dirname($dir)));
|
header('Location: /admin/content?dir=' . urlencode(dirname($dir)));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,7 +1958,7 @@ function handleContentMove($auth, $config, $twig, $user, $csrf): void
|
|||||||
if (!file_exists($newPath)) {
|
if (!file_exists($newPath)) {
|
||||||
rename($fullPath, $newPath);
|
rename($fullPath, $newPath);
|
||||||
adminLog($config, 'info', $user['username'] . ' verplaatste ' . $item . ' naar ' . $dest);
|
adminLog($config, 'info', $user['username'] . ' verplaatste ' . $item . ' naar ' . $dest);
|
||||||
header('Location: /admin/content-list?dir=' . urlencode($dest));
|
header('Location: /admin/content?dir=' . urlencode($dest));
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$message = 'Bestand of map bestaat al op de bestemming.';
|
$message = 'Bestand of map bestaat al op de bestemming.';
|
||||||
@@ -4168,7 +4254,9 @@ function handleMediaList($auth, $config): void
|
|||||||
$urlPrefix = '/themes/' . $theme . '/assets/';
|
$urlPrefix = '/themes/' . $theme . '/assets/';
|
||||||
} else {
|
} else {
|
||||||
$baseDir = $config['content_dir'];
|
$baseDir = $config['content_dir'];
|
||||||
$urlPrefix = '/content/';
|
// Serve content media through the /-media/ gateway; /content/ is
|
||||||
|
// blocked (403) by public/index.php and lives outside the webroot.
|
||||||
|
$urlPrefix = '/-media/';
|
||||||
}
|
}
|
||||||
|
|
||||||
$realBase = realpath($baseDir);
|
$realBase = realpath($baseDir);
|
||||||
@@ -4311,7 +4399,7 @@ function scanContentDir(string $fullPath, string $subdir): array
|
|||||||
|
|
||||||
$files = scandir($fullPath);
|
$files = scandir($fullPath);
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
if ($file === '.' || $file === '..' || str_starts_with($file, '.')) {
|
if ($file === '.' || $file === '..') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4372,7 +4460,7 @@ function scanEditorFiles(string $dir, string $scope = 'plugin'): array
|
|||||||
if ($scope === 'theme') {
|
if ($scope === 'theme') {
|
||||||
$skipPaths['assets/css_compiled'] = true;
|
$skipPaths['assets/css_compiled'] = true;
|
||||||
}
|
}
|
||||||
return scanEditorFilesNode($realBase, '', $realBase, $skipPaths);
|
return scanEditorFilesNode($realBase, '', $realBase, $skipPaths, $scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4382,8 +4470,9 @@ function scanEditorFiles(string $dir, string $scope = 'plugin'): array
|
|||||||
* @param string $relPath Relative path of $absDir within the base (empty for root)
|
* @param string $relPath Relative path of $absDir within the base (empty for root)
|
||||||
* @param string $realBase The real base root (for path-traversal guard)
|
* @param string $realBase The real base root (for path-traversal guard)
|
||||||
* @param array $skipPaths Map of relative paths to skip (e.g. ['assets/css_compiled' => true])
|
* @param array $skipPaths Map of relative paths to skip (e.g. ['assets/css_compiled' => true])
|
||||||
|
* @param string $scope 'plugin' | 'theme' | 'content' — content toont . bestanden/mappen
|
||||||
*/
|
*/
|
||||||
function scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = []): array
|
function scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = [], string $scope = 'plugin'): array
|
||||||
{
|
{
|
||||||
$nodes = [];
|
$nodes = [];
|
||||||
if (!is_dir($absDir)) {
|
if (!is_dir($absDir)) {
|
||||||
@@ -4397,10 +4486,16 @@ function scanEditorFilesNode(string $absDir, string $relPath, string $realBase,
|
|||||||
if ($entry === '.' || $entry === '..') {
|
if ($entry === '.' || $entry === '..') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Skip hidden / dotfiles (incl. .gitkeep, .mtime, .bak, .git)
|
// Plugin/theme scope: skip dotfiles (.gitkeep, .mtime, .bak, .git)
|
||||||
|
// Content scope: toon . bestanden/mappen (bijv. .map), maar sla .git/.gitkeep over
|
||||||
if ($entry !== '' && $entry[0] === '.') {
|
if ($entry !== '' && $entry[0] === '.') {
|
||||||
|
if ($scope !== 'content') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($entry === '.git' || $entry === '.gitkeep') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$childRelPath = $relPath === '' ? $entry : ($relPath . '/' . $entry);
|
$childRelPath = $relPath === '' ? $entry : ($relPath . '/' . $entry);
|
||||||
// Skip scope-specific paths (e.g. theme css_compiled runtime artefact)
|
// Skip scope-specific paths (e.g. theme css_compiled runtime artefact)
|
||||||
@@ -4428,7 +4523,7 @@ function scanEditorFilesNode(string $absDir, string $relPath, string $realBase,
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($isDir) {
|
if ($isDir) {
|
||||||
$node['children'] = scanEditorFilesNode($realChild, $childRelPath, $realBase, $skipPaths);
|
$node['children'] = scanEditorFilesNode($realChild, $childRelPath, $realBase, $skipPaths, $scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
$nodes[] = $node;
|
$nodes[] = $node;
|
||||||
@@ -4500,7 +4595,7 @@ function collectContentPages(string $contentDir): array
|
|||||||
// Skip hidden / dash-prefixed segments (private assets etc.)
|
// Skip hidden / dash-prefixed segments (private assets etc.)
|
||||||
$skip = false;
|
$skip = false;
|
||||||
foreach (explode('/', $relative) as $segment) {
|
foreach (explode('/', $relative) as $segment) {
|
||||||
if ($segment !== '' && ($segment[0] === '.' || $segment[0] === '-')) {
|
if ($segment !== '' && $segment[0] === '-') {
|
||||||
$skip = true;
|
$skip = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'version' => '2.6.2',
|
'version' => '2.6.3',
|
||||||
'release_date' => '2026-08-19',
|
'release_date' => '2026-08-20',
|
||||||
'codename' => 'Lyra',
|
'codename' => 'Lyra',
|
||||||
'status' => 'stable',
|
'status' => 'stable',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user