Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5ca7cab4 | ||
|
|
88fbaa5702 | ||
|
|
c2cf08955b | ||
|
|
bd4fe54d09 | ||
|
|
94f9b3d831 | ||
|
|
20adea7544 | ||
|
|
9a5ab351ba | ||
|
|
a9e3b023de | ||
|
|
6ed29e2c6c | ||
|
|
ec7fec1c1e | ||
|
|
4e77ef8670 | ||
|
|
9ec4910674 | ||
|
|
03a14126ca | ||
|
|
342112cd21 | ||
|
|
1a748cbc77 | ||
|
|
a03c91faf2 | ||
|
|
0dae0094f5 | ||
|
|
689a1a5d81 | ||
|
|
a9388df019 | ||
|
|
942393f7f9 | ||
|
|
f6650fafdb | ||
|
|
884c0141c6 | ||
|
|
f70506616b | ||
|
|
0076d7bd8d | ||
|
|
0fbb9bb74c | ||
|
|
d8e37f0bf6 | ||
|
|
3a55ea4db6 | ||
|
|
7fc3847bbb | ||
|
|
c854c18687 | ||
|
|
aedabed09d | ||
|
|
ab5dc31513 | ||
|
|
6333bc410f | ||
|
|
d453b8073f | ||
|
|
ebc3841a17 | ||
|
|
fd393250fc | ||
|
|
44a1e6dc96 | ||
|
|
06785e9922 | ||
|
|
8c6b38c2c5 | ||
|
|
433efce56e | ||
|
|
375a39c458 | ||
|
|
5884877f18 | ||
|
|
959f109f9e | ||
|
|
f81be9e047 | ||
|
|
b8d4a4e6d5 | ||
|
|
b203f6d12a | ||
|
|
2faea90872 | ||
|
|
8b454cd038 | ||
|
|
74487d4820 | ||
|
|
99a4c20e97 | ||
|
|
28c8e895c5 | ||
|
|
df8394b057 | ||
|
|
13dd72d14d | ||
|
|
baf8f56878 | ||
|
|
a162fd9614 | ||
|
|
0dc7b15fcf | ||
|
|
596b745bad | ||
|
|
024328c871 | ||
|
|
f8be943550 | ||
|
|
f72d7e7b01 | ||
|
|
2fdf90519a | ||
|
|
e80967f0fe | ||
|
|
eb8064bfbc | ||
|
|
2244fcbee2 | ||
|
|
064e905f31 | ||
|
|
0ccd89824f | ||
|
|
5db721c2c3 | ||
|
|
c244514a48 | ||
|
|
98f74b2861 | ||
|
|
6272538875 | ||
|
|
d5bd0097ff | ||
|
|
e14f418e63 | ||
|
|
dc0d370e65 | ||
|
|
d97c67c6a9 | ||
|
|
f5d95fd344 | ||
|
|
7728336fa3 | ||
|
|
8e18a5d87a |
+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.3 | **License:** AGPL v3 / Commercial
|
**Version:** 2.6.1 | **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.3 | **Licentie:** AGPL v3 / Commercial
|
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## bug
|
## Voor elke versie verhoging. Deze nooit weghalen.
|
||||||
- [x] Admin /content
|
- [x] Pentest controles uitgevoerd
|
||||||
- [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] WCAG 2.1 AA accessibility tests
|
||||||
- [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] Volledige git commit maken.
|
||||||
- [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree
|
- [x] Verslag maken voor opdrcht gever
|
||||||
- [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] Na convormatie versie verhogen
|
||||||
- [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent.
|
- [x] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde.
|
||||||
- [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 ---
|
## Bug fixes
|
||||||
- [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin
|
- [x] admin rollen werkt niet goed, dashboard geeft een 404, de plugin's moeten aangege welke rol nodig is.
|
||||||
- [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt.
|
- [x] user admin mag nooit zijn eigen rol weizigen.
|
||||||
- [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/
|
- [x] Na in loggen moet de admin zijn rol binnen de /admin kunnen veranderen om andere rollen te kunnen testen
|
||||||
|
- [x] in een andere rol kan de gebruiker niet zijn eigen wachtwoord veranderen.
|
||||||
|
- [x] Bij het aanmaken van een pagina in de content moet de auteur naam en email in de meta komen te staan. Deze moeten dus ook door middel van de api aan de twig bestanden doorgeven kunnen worden
|
||||||
|
- [x] Admin taal niet volledig geïntegreerd met plugins: plugins hebben nu eigen language/ mappen (nl/en). Systeem plugins volgen admin taal (admin.php), content plugins volgen content taal (site.php). Fallback chain: geselecteerd → plugin default_language → CMS default → leeg. PluginManager + AdminPluginAPI + CMSAPI uitgebreid met getPluginTranslations()/t(). plugin.json settings ondersteunen label_key/help_key/option_label_key.
|
||||||
|
- [x] Plugin uniformiteit: alle 6 plugins hebben nu een uniforme structuur (<Plugin>.php, plugin.json, README.md, assets/.gitkeep, language/nl|en/). plugins/README.md herschreven. guide plugin-development.md (NL+EN) volledig bijgewerkt met i18n + admin integratie + instellingen-schema. guide architectuur.md (NL+EN) bijgewerkt met plugin language/ map.
|
||||||
|
- [x] Plugin editor toont alleen de <Plugin>.php maar een plugin kan uit meerdere bestanden bestaan: plugins-edit pagina heeft nu een bestandsbrowser zijbalk die alle bestanden in de plugin-map toont (.php, .json, .md, .html, .css, .scss, .js) inclusief language/ en assets/ submappen. Nieuw-bestand knop met modal. Path-traversal bescherming via realpath + prefix check + segment-guard. scanPluginFiles() helper toegevoegd. editor-toolbar.js modeMap uitgebreid voor css/scss/js/json. Vertaalstrings toegevoegd aan nl/en/de admin.php.
|
||||||
|
- [x] Plugin editor uploaden en verwijderen: plugins-edit pagina heeft nu een Upload knop (bestanden naar assets/ van de plugin) en per-bestand verwijder-knop in de bestandsboom. Twee nieuwe routes: plugins-file-upload en plugins-file-delete. Path-traversal bescherming + protected plugins geblokkeerd + dotfiles geweigerd. Vertaalstrings toegevoegd.
|
||||||
|
- [x] Plugin editor bestand verplaatsen: plugins-edit pagina heeft nu per-bestand een verplaats-knop in de bestandsboom. Nieuwe route plugins-file-move + template plugins-move-form.twig (dropdown met mappen binnen de plugin, plugin root als optie, huidige map uitgesloten). Path-traversal bescherming + protected plugins geblokkeerd.
|
||||||
|
|
||||||
|
|
||||||
## 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)
|
||||||
@@ -53,34 +61,6 @@
|
|||||||
- [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) ✅
|
|
||||||
- [x] Pentest controles uitgevoerd (30/30)
|
|
||||||
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
|
||||||
- [x] Thema editor (thema's net zo bewerkbaar maken als plugins): bestandsbrowser zijbalk, nieuw bestand, uploaden, verwijderen, verplaatsen, SCSS compileren vanuit editor, thema activeren/verwijderen, nieuw thema met basis-kopie, uniforme thema-structuur
|
|
||||||
- [x] Content editor (content consistent met plugins en thema's): bestandsbrowser zijbalk nu op /admin/content (vervangt tabelweergave), nieuw bestand/map, uploaden, verwijderen, verplaatsen, mappen beheer, layout/plugins selectie, git/backup integratie in editor zijbalk; oude tabelweergave verhuisd naar /admin/content-list
|
|
||||||
- [x] Uniformiteit: scanEditorFiles() unified scanner (scanPluginFiles/scanThemeFiles/scanContentFiles als dunne wrappers)
|
|
||||||
- [x] Media invoegen in editor: ?theme= scope voor media-list endpoint + _media-modal.twig
|
|
||||||
- [x] Bug: thema-naam niet overgenomen bij kopiëren — title in theme.json wordt overschreven met nieuwe themanaam; README.md krijgt nieuwe header
|
|
||||||
- [x] Bug: mappen met `_` prefix zichtbaar in frontend navigatie — scanDirectory/searchInDirectory/scanForPageTitles skippen nu `_` prefix (consistente filtering met `.` en `-`)
|
|
||||||
- [x] Bug: images in markdown niet weergegeven — processContent() herschrijft lokale image/link URLs naar /-media/ endpoint (relatief, /content/, subdir, custom-grootte syntax); externe URLs ongewijzigd gelaten
|
|
||||||
- [x] UX: /admin/content is nu de boom-editor (plugin-file-tree structuur); oude tabelweergave op /admin/content-list met "Boom weergave"/"Lijst weergave" knoppen
|
|
||||||
- [x] UX: image-grootte knop in editor-toolbar (markdown) — selecteer , klik knop, breedte/hoogte dialog, {:width=... height=...} syntax toevoegen/vervangen
|
|
||||||
- [x] Handleidingen in guide/ bijgewerkt (thema-beheer.md NL+EN volledig herschreven met thema-editor uitleg; content-beheer.md NL+EN volledig herschreven met content-editor uitleg)
|
|
||||||
- [x] Verslag gemaakt (docs/release-notes/v2.6.2.md)
|
|
||||||
- [x] Versie verhoogd naar 2.6.2
|
|
||||||
|
|
||||||
## v2.6.1d (2026-08-18) ✅
|
## v2.6.1d (2026-08-18) ✅
|
||||||
- [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)
|
||||||
@@ -90,13 +70,6 @@
|
|||||||
- [x] Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare _media-modal.twig include; plugin-context scant assets/ map
|
- [x] Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare _media-modal.twig include; plugin-context scant assets/ map
|
||||||
- [x] Plugin overzicht knoppen: alleen iconen met title/aria-label
|
- [x] Plugin overzicht knoppen: alleen iconen met title/aria-label
|
||||||
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt (plugin-development.md NL+EN volledig herschreven, architectuur.md NL+EN bijgewerkt)
|
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt (plugin-development.md NL+EN volledig herschreven, architectuur.md NL+EN bijgewerkt)
|
||||||
- [x] Bug: admin taal niet volledig geïntegreerd met plugins — plugins hebben nu eigen language/ mappen (nl/en), systeem plugins volgen admin taal (admin.php), content plugins volgen content taal (site.php), fallback chain, PluginManager + AdminPluginAPI + CMSAPI uitgebreid met getPluginTranslations()/t(), plugin.json settings ondersteunen label_key/help_key/option_label_key
|
|
||||||
- [x] Bug: plugin uniformiteit — alle 6 plugins hebben nu een uniforme structuur (<Plugin>.php, plugin.json, README.md, assets/.gitkeep, language/nl|en/), plugins/README.md herschreven, guide plugin-development.md (NL+EN) volledig bijgewerkt, guide architectuur.md (NL+EN) bijgewerkt
|
|
||||||
- [x] Bug: plugin editor toont alleen de <Plugin>.php maar een plugin kan uit meerdere bestanden bestaan — plugins-edit pagina heeft nu een bestandsbrowser zijbalk die alle bestanden in de plugin-map toont (.php, .json, .md, .html, .css, .scss, .js) inclusief language/ en assets/ submappen, nieuw-bestand knop met modal, path-traversal bescherming, scanPluginFiles() helper, editor-toolbar.js modeMap uitgebreid
|
|
||||||
- [x] Bug: plugin editor uploaden en verwijderen — plugins-edit pagina heeft nu een Upload knop (bestanden naar assets/ van de plugin) en per-bestand verwijder-knop in de bestandsboom, twee nieuwe routes (plugins-file-upload, plugins-file-delete), path-traversal bescherming + protected plugins geblokkeerd + dotfiles geweigerd
|
|
||||||
- [x] Bug: plugin editor bestand verplaatsen — plugins-edit pagina heeft nu per-bestand een verplaats-knop in de bestandsboom, nieuwe route plugins-file-move + template plugins-move-form.twig, path-traversal bescherming + protected plugins geblokkeerd
|
|
||||||
- [x] Bug: media-knop in editor werkte niet — #mediaModal bestond niet, opgelost via nieuw /admin/media-list JSON endpoint + herbruikbare _media-modal.twig include
|
|
||||||
- [x] Bug: plugin-kaart knoppen tekst liep niet goed — teksten verwijderd, alleen iconen met title/aria-label
|
|
||||||
- [x] Verslag gemaakt (docs/release-notes/v2.6.1d.md)
|
- [x] Verslag gemaakt (docs/release-notes/v2.6.1d.md)
|
||||||
- [x] Versie verhoogd naar 2.6.1d
|
- [x] Versie verhoogd naar 2.6.1d
|
||||||
|
|
||||||
@@ -106,11 +79,6 @@
|
|||||||
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt
|
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt
|
||||||
- [x] Admin gebruikersbeheer opnieuw ontworpen (lijst + zoeken/filter + profiel + wachtwoord)
|
- [x] Admin gebruikersbeheer opnieuw ontworpen (lijst + zoeken/filter + profiel + wachtwoord)
|
||||||
- [x] CLI commando voor admin wachtwoord reset (cli/reset-admin-password.php)
|
- [x] CLI commando voor admin wachtwoord reset (cli/reset-admin-password.php)
|
||||||
- [x] Bug: admin rollen werkt niet goed, dashboard geeft een 404, de plugin's moeten aangeven welke rol nodig is
|
|
||||||
- [x] Bug: user admin mag nooit zijn eigen rol wijzigen
|
|
||||||
- [x] Bug: na inloggen moet de admin zijn rol binnen de /admin kunnen veranderen om andere rollen te kunnen testen
|
|
||||||
- [x] Bug: in een andere rol kan de gebruiker niet zijn eigen wachtwoord veranderen
|
|
||||||
- [x] Bug: bij het aanmaken van een pagina in de content moet de auteur naam en email in de meta komen te staan (door middel van de api aan de twig bestanden doorgeven)
|
|
||||||
- [x] Verslag gemaakt (docs/release-notes/v2.6.1c.md)
|
- [x] Verslag gemaakt (docs/release-notes/v2.6.1c.md)
|
||||||
- [x] Versie verhoogd naar 2.6.1c
|
- [x] Versie verhoogd naar 2.6.1c
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password_hash": "$2y$12$CV2uORTIiie6s3jf9ANaneL7VLNqA5smXyrIzkcJFjlS2Vxfw1/HK",
|
||||||
|
"role": "admin",
|
||||||
|
"email": "",
|
||||||
|
"author_name": "",
|
||||||
|
"author_email": "",
|
||||||
|
"created": "2025-01-01"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"session_timeout": 1800,
|
||||||
|
"max_login_attempts": 5,
|
||||||
|
"lockout_duration": 900
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"default_page": "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@ class AdminAuth
|
|||||||
*/
|
*/
|
||||||
public const ROLE_PERMISSIONS = [
|
public const ROLE_PERMISSIONS = [
|
||||||
'admin' => ['*'],
|
'admin' => ['*'],
|
||||||
'content-manager' => ['dashboard', 'content', 'content-list', 'content-files', 'content-edit', 'content-new', 'content-delete', 'content-file-upload', 'content-file-delete', 'content-file-move', 'content-dir-create', 'content-dir-create-in', 'content-dir-rename', 'content-dir-rename-in', 'content-dir-delete', 'content-dir-delete-in', 'content-move', 'content-backup', 'content-restore', 'content-git-init', 'content-git-commit', 'content-git-restore', 'guide', 'logout'],
|
'content-manager' => ['dashboard', 'content', 'content-edit', 'content-new', 'content-delete', 'content-dir-create', 'content-dir-rename', 'content-dir-delete', 'content-move', 'content-backup', 'content-restore', 'content-git-init', 'content-git-commit', 'content-git-restore', 'guide', 'logout'],
|
||||||
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
||||||
'site-admin' => ['dashboard', 'theme', 'theme-new', 'theme-edit', 'theme-file-upload', 'theme-file-delete', 'theme-file-move', 'theme-activate', 'theme-delete', 'theme-scss', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
|
'site-admin' => ['dashboard', 'theme', 'theme-new', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
var ext = textarea.dataset.ext || 'md';
|
var ext = textarea.dataset.ext || 'md';
|
||||||
var form = document.getElementById('editor-form') || textarea.closest('form');
|
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||||
|
|
||||||
var modeMap = { md: 'markdown', html: 'htmlmixed', twig: 'htmlmixed', php: 'php', css: 'css', scss: 'css', js: 'javascript', json: 'javascript' };
|
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php', css: 'css', scss: 'css', js: 'javascript', json: 'javascript' };
|
||||||
|
|
||||||
var editor = CodeMirror.fromTextArea(textarea, {
|
var editor = CodeMirror.fromTextArea(textarea, {
|
||||||
mode: modeMap[ext] || 'markdown',
|
mode: modeMap[ext] || 'markdown',
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
{ cmd: 'quote', icon: 'bi-chat-quote', title: 'Citaat' },
|
{ cmd: 'quote', icon: 'bi-chat-quote', title: 'Citaat' },
|
||||||
null,
|
null,
|
||||||
{ cmd: 'hr', icon: 'bi-hr', title: 'Horizontale lijn' },
|
{ cmd: 'hr', icon: 'bi-hr', title: 'Horizontale lijn' },
|
||||||
{ cmd: 'image-size', icon: 'bi-arrows-angle-expand', title: 'Afbeelding grootte' },
|
|
||||||
null,
|
null,
|
||||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||||
],
|
],
|
||||||
@@ -153,43 +152,6 @@
|
|||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'image-size':
|
|
||||||
// Set width/height on a markdown image: {:width="W" height="H"}
|
|
||||||
var selImg = editor.getSelection();
|
|
||||||
if (!selImg) {
|
|
||||||
// No selection — ask the user to select an image first
|
|
||||||
alert('Selecteer eerst een afbeelding in de editor () om de grootte in te stellen.');
|
|
||||||
editor.focus();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Match  optionally followed by {:...}
|
|
||||||
var imgMatch = selImg.match(/^!\[([^\]]*)\]\(([^)]+)\)(\s*\{:([^}]*)\})?/);
|
|
||||||
if (!imgMatch) {
|
|
||||||
alert('Selectie is geen afbeelding. Selecteer een afbeelding in markdown formaat: ');
|
|
||||||
editor.focus();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
var alt = imgMatch[1];
|
|
||||||
var url = imgMatch[2];
|
|
||||||
// Parse existing width/height from the {:...} block
|
|
||||||
var existingAttrs = imgMatch[4] || '';
|
|
||||||
var existingW = (existingAttrs.match(/width\s*=\s*"([^"]*)"/) || [])[1] || '';
|
|
||||||
var existingH = (existingAttrs.match(/height\s*=\s*"([^"]*)"/) || [])[1] || '';
|
|
||||||
var width = prompt('Breedte (px of %, leeg = niet instellen):', existingW);
|
|
||||||
if (width === null) { editor.focus(); break; }
|
|
||||||
var height = prompt('Hoogte (px of %, leeg = niet instellen):', existingH);
|
|
||||||
if (height === null) { editor.focus(); break; }
|
|
||||||
// Build the {:width=... height=...} suffix
|
|
||||||
var attrs = [];
|
|
||||||
if (width.trim() !== '') attrs.push('width="' + width.replace(/"/g, '') + '"');
|
|
||||||
if (height.trim() !== '') attrs.push('height="' + height.replace(/"/g, '') + '"');
|
|
||||||
var replacement = '';
|
|
||||||
if (attrs.length > 0) {
|
|
||||||
replacement += '{:' + attrs.join(' ') + '}';
|
|
||||||
}
|
|
||||||
editor.replaceSelection(replacement);
|
|
||||||
editor.focus();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<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') }}; height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; overflow-y: auto; }
|
.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 .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; }
|
||||||
@@ -23,7 +24,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%; height: auto; position: relative; }
|
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
||||||
.admin-main { margin-left: 0; }
|
.admin-main { margin-left: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="admin-sidebar" id="adminSidebar">
|
<nav class="admin-sidebar d-flex flex-column">
|
||||||
<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>
|
||||||
@@ -133,7 +134,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="nav flex-column mt-3 mb-4">
|
<ul class="nav flex-column mt-auto mb-5">
|
||||||
{% 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">
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
{# 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>
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
{# 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,17 +1,12 @@
|
|||||||
{# 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 renders them as a
|
of media files from /admin/media-list (JSON) and, on click, inserts a
|
||||||
collapsible file tree (consistent with the content/plugin/theme editors).
|
snippet into the active CodeMirror editor at the cursor.
|
||||||
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>
|
|
||||||
which scans themes/<name>/assets/ instead of content/.
|
|
||||||
|
|
||||||
The snippet format depends on the editor's current mode (data-ext):
|
The snippet format depends on the editor's current mode (data-ext):
|
||||||
- markdown (md): 
|
- markdown (md): 
|
||||||
@@ -29,20 +24,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="mediaModalTree" class="media-tree-wrap">
|
<div id="mediaModalGrid" class="row g-3">
|
||||||
<div class="text-center text-muted py-4" id="mediaModalLoading">
|
<div class="col-12 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="text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
<div class="col-12 text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
||||||
<i class="bi bi-image display-6 d-block mb-2"></i>
|
<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="text-center text-danger py-4 d-none" id="mediaModalError">
|
<div class="col-12 text-center text-danger py-4 d-none" id="mediaModalError">
|
||||||
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
|
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
|
||||||
<span id="mediaModalErrorText"></span>
|
<span id="mediaModalErrorText"></span>
|
||||||
</div>
|
</div>
|
||||||
{# Tree populated by JS #}
|
{# Filled by JS #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -52,22 +47,6 @@
|
|||||||
</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';
|
||||||
@@ -95,6 +74,7 @@
|
|||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,172 +83,89 @@
|
|||||||
return map[ext] || 'markdown';
|
return map[ext] || 'markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconFor(ext, isImage) {
|
function renderGrid() {
|
||||||
if (isImage) return 'bi-file-image text-info';
|
var grid = document.getElementById('mediaModalGrid');
|
||||||
if (ext === 'pdf') return 'bi-file-pdf text-danger';
|
if (!grid) return;
|
||||||
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
|
|
||||||
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
|
|
||||||
return 'bi-file text-muted';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a nested tree from a flat list of {path, name, ...} items.
|
// Clear previous items (keep loading/empty/error placeholders)
|
||||||
function buildTree(flat) {
|
Array.prototype.forEach.call(grid.querySelectorAll('[data-media-item]'), function (el) { el.remove(); });
|
||||||
var root = { name: '', path: '', children: {}, isDir: true };
|
|
||||||
flat.forEach(function (item) {
|
|
||||||
var parts = item.path.split('/');
|
|
||||||
var node = root;
|
|
||||||
for (var i = 0; i < parts.length; i++) {
|
|
||||||
var part = parts[i];
|
|
||||||
if (part === '') continue;
|
|
||||||
var isLast = i === parts.length - 1;
|
|
||||||
if (!node.children[part]) {
|
|
||||||
node.children[part] = {
|
|
||||||
name: part, path: parts.slice(0, i + 1).join('/'),
|
|
||||||
children: {}, isDir: !isLast
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isLast) {
|
|
||||||
node.children[part].item = item;
|
|
||||||
node.children[part].isDir = false;
|
|
||||||
}
|
|
||||||
node = node.children[part];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Convert children maps to sorted arrays (dirs first, then alpha)
|
|
||||||
function finalize(n) {
|
|
||||||
var arr = Object.keys(n.children).map(function (k) { return n.children[k]; });
|
|
||||||
arr.sort(function (a, b) {
|
|
||||||
if (a.isDir && !b.isDir) return -1;
|
|
||||||
if (!a.isDir && b.isDir) return 1;
|
|
||||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
n.childArr = arr;
|
|
||||||
arr.forEach(finalize);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
finalize(root);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesFilter(node, q) {
|
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
||||||
if (!q) return true;
|
var shown = 0;
|
||||||
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) {
|
items.forEach(function (item) {
|
||||||
return String(s).replace(/[&<>"']/g, function (c) {
|
if (q && item.name.toLowerCase().indexOf(q) === -1) return;
|
||||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
shown++;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var idCounter = 0;
|
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 + ')';
|
||||||
|
|
||||||
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) {
|
if (item.is_image) {
|
||||||
thumb = '<img class="media-thumb" src="' + item.url + '" alt="" loading="lazy">';
|
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 {
|
} else {
|
||||||
thumb = '<i class="bi ' + iconFor(item.extension, item.is_image) + '"></i>';
|
var ph = document.createElement('div');
|
||||||
}
|
ph.className = 'card-img-top d-flex align-items-center justify-content-center bg-light';
|
||||||
a.innerHTML = '<span class="tree-chevron-spacer"></span>' + thumb +
|
ph.style.height = '120px';
|
||||||
'<span class="text-truncate">' + escapeHtml(item.name) + '</span>' +
|
var icon = document.createElement('i');
|
||||||
'<span class="badge bg-secondary badge-ext">' + item.extension.toUpperCase() + '</span>';
|
icon.className = 'bi bi-file-earmark-play display-6 text-muted';
|
||||||
a.addEventListener('click', function (e) {
|
ph.appendChild(icon);
|
||||||
e.preventDefault();
|
card.appendChild(ph);
|
||||||
insertItem(item);
|
|
||||||
});
|
|
||||||
li.appendChild(a);
|
|
||||||
}
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
container.appendChild(ul);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertItem(item) {
|
var body = document.createElement('div');
|
||||||
|
body.className = 'card-body p-2';
|
||||||
|
var label = document.createElement('p');
|
||||||
|
label.className = 'card-text small text-muted text-truncate mb-0';
|
||||||
|
label.textContent = item.name;
|
||||||
|
body.appendChild(label);
|
||||||
|
card.appendChild(body);
|
||||||
|
|
||||||
|
card.addEventListener('click', function () {
|
||||||
var editor = window.codeMirrorEditor;
|
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();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function renderTree() {
|
col.appendChild(card);
|
||||||
var wrap = document.getElementById('mediaModalTree');
|
grid.appendChild(col);
|
||||||
if (!wrap) return;
|
});
|
||||||
// Clear previous tree (keep placeholders)
|
|
||||||
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
|
// Toggle empty placeholder
|
||||||
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
|
||||||
var tree = buildTree(items);
|
|
||||||
var shown = tree.childArr.length;
|
|
||||||
if (shown > 0) {
|
|
||||||
// When filtering, auto-expand all dirs by rendering without collapsed class
|
|
||||||
renderNode(tree, wrap, q);
|
|
||||||
}
|
|
||||||
var loading = document.getElementById('mediaModalLoading');
|
|
||||||
var empty = document.getElementById('mediaModalEmpty');
|
var empty = document.getElementById('mediaModalEmpty');
|
||||||
|
var loading = document.getElementById('mediaModalLoading');
|
||||||
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) { renderTree(); return; }
|
if (loaded) { renderGrid(); return; }
|
||||||
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
||||||
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
|
||||||
var url = '/admin/media-list';
|
var url = '/admin/media-list';
|
||||||
if (plugin) { url += '?plugin=' + encodeURIComponent(plugin); }
|
if (plugin) { url += '?plugin=' + encodeURIComponent(plugin); }
|
||||||
else if (theme) { url += '?theme=' + encodeURIComponent(theme); }
|
|
||||||
fetch(url, { credentials: 'same-origin' })
|
fetch(url, { credentials: 'same-origin' })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
@@ -277,7 +174,7 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
items = Array.isArray(data) ? data : [];
|
items = Array.isArray(data) ? data : [];
|
||||||
loaded = true;
|
loaded = true;
|
||||||
renderTree();
|
renderGrid();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
var loading = document.getElementById('mediaModalLoading');
|
var loading = document.getElementById('mediaModalLoading');
|
||||||
@@ -289,26 +186,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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', renderTree);
|
filter.addEventListener('input', renderGrid);
|
||||||
}
|
|
||||||
|
|
||||||
// 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" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
|
||||||
|
|
||||||
{% block title %}{{ ta.rename|default('Hernoemen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
||||||
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.rename_folder|default('Map hernoemen') }}</h2>
|
|
||||||
<a href="/admin/content" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/content-dir-rename-in?dir={{ dir|url_encode }}" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="dir" value="{{ dir }}">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
{{ ta.rename_prefix|default('Hernoem') }} <strong>{{ currentName }}</strong>
|
|
||||||
<span class="text-muted">(content/{{ dir }})</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="newname" class="form-label">{{ ta.new_name|default('Nieuwe naam') }}</label>
|
|
||||||
<input type="text" class="form-control" id="newname" name="newname" value="{{ currentName }}" required autofocus>
|
|
||||||
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-white">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
|
|
||||||
</button>
|
|
||||||
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
|
||||||
@@ -15,8 +11,12 @@
|
|||||||
<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 class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
<p class="form-control-plaintext mb-0"><code>{{ fileName }}</code></p>
|
<div class="input-group">
|
||||||
|
<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,28 +29,30 @@
|
|||||||
</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">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||||
<div class="plugin-checkbox-group">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
{% for plugin in availablePlugins %}
|
{% for plugin in availablePlugins %}
|
||||||
<div class="form-check">
|
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
||||||
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
||||||
<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 (currentCreated or currentEdited) %}
|
{% if isEditable and (currentAuthorName or currentAuthorEmail or currentCreated) %}
|
||||||
<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-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
|
||||||
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label text-muted"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
<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="{{ currentEdited }}" readonly>
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -65,11 +67,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 %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
<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') }}">
|
||||||
<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" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
<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;
|
||||||
|
|
||||||
@@ -91,6 +94,10 @@ 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 () {
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
|
||||||
|
|
||||||
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
|
||||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
|
||||||
{% include 'pages/_editor-styles.twig' %}
|
|
||||||
<style>
|
|
||||||
/* Git status badge (content editor only) */
|
|
||||||
.git-status { font-size: .75rem; }
|
|
||||||
.git-status .badge { font-weight: 400; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
||||||
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.content_editor|default('Content editor') }}</h2>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
{# Backup integratie in zijbalk header (git wordt later een systeem plugin) #}
|
|
||||||
<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>
|
|
||||||
<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') }}
|
|
||||||
</button>
|
|
||||||
<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') }}
|
|
||||||
</button>
|
|
||||||
<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') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Upload form (collapsed by default) #}
|
|
||||||
<div class="collapse mb-3" id="contentUploadForm">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/content-file-upload" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="dir" value="" id="contentUploadDir">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="contentUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
|
||||||
<input type="file" class="form-control" id="contentUploadFile" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav,.css,.scss,.js,.json,.html,.md">
|
|
||||||
<small class="form-text text-muted">{{ ta.content_upload_help|default('Bestanden worden geüpload naar content/. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.') }}</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-layout">
|
|
||||||
{# File tree sidebar (shared partial) #}
|
|
||||||
{% include 'pages/_file-tree.twig' with {
|
|
||||||
'files': files,
|
|
||||||
'relFile': relFile,
|
|
||||||
'editableExts': editableExts,
|
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'treeIdPrefix': 'content-tree',
|
|
||||||
'treeHeader': ta.content_files|default('Content bestanden'),
|
|
||||||
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
|
||||||
'treeRouteBase': '/admin/content',
|
|
||||||
'treeRouteParams': '',
|
|
||||||
'treeDeleteRoute': '/admin/content-file-delete',
|
|
||||||
'treeDeleteName': '',
|
|
||||||
'treeDeleteValue': '',
|
|
||||||
'treeMoveRoute': '/admin/content-file-move',
|
|
||||||
'treeMoveParams': '',
|
|
||||||
'treeDirActions': true,
|
|
||||||
'treeHideActionsFor': [],
|
|
||||||
} %}
|
|
||||||
|
|
||||||
{# Editor main panel #}
|
|
||||||
<div class="editor-main">
|
|
||||||
{% if relFile %}
|
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
|
||||||
<ol class="breadcrumb mb-0">
|
|
||||||
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> content</li>
|
|
||||||
{% set crumbPath = '' %}
|
|
||||||
{% set parts = relFile|split('/') %}
|
|
||||||
{% for part in parts %}
|
|
||||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
|
||||||
{% if loop.last %}
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="breadcrumb-item"><a href="/admin/content?file={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if isEditable %}
|
|
||||||
<form method="POST" action="/admin/content?file={{ relFile|url_encode }}" id="editor-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
{# Metadata row: filename display, layout selector, plugins #}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="new_filename" class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
|
|
||||||
<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 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>
|
|
||||||
<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 %}
|
|
||||||
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% if availablePlugins is not empty %}
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
|
||||||
<div class="plugin-checkbox-group">
|
|
||||||
{% for plugin in availablePlugins %}
|
|
||||||
<div class="form-check">
|
|
||||||
<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 %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{# Created / edited timestamps (read-only, auto-filled) #}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<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="{{ currentCreated }}" readonly>
|
|
||||||
</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 class="editor-toolbar" id="editor-toolbar"></div>
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
|
||||||
</button>
|
|
||||||
<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') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
|
|
||||||
{% if relFile %}
|
|
||||||
{{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
|
||||||
{% else %}
|
|
||||||
{{ ta.content_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# New file modal #}
|
|
||||||
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form method="POST" action="/admin/content">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="action" value="new_file">
|
|
||||||
<input type="hidden" name="in_dir" id="newFileDir" value="">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="newFilenameInput" class="form-label">{{ ta.content_file_path|default('Bestandspad binnen content') }}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. nl.pagina of blog/nl.post" required autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">{{ ta.content_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. De extensie wordt hieronder gekozen.') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="newExtSelect" class="form-label">{{ ta.file_type|default('Bestandstype') }}</label>
|
|
||||||
<select class="form-select" id="newExtSelect" name="new_ext">
|
|
||||||
<option value="md">Markdown (.md)</option>
|
|
||||||
<option value="php">PHP (.php)</option>
|
|
||||||
<option value="html">HTML (.html)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# New directory modal #}
|
|
||||||
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form method="POST" action="/admin/content-dir-create-in">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="in_dir" id="newDirInDir" value="">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="newDirModalLabel"><i class="bi bi-folder-plus"></i> {{ ta.new_folder_title|default('Nieuwe map aanmaken') }}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="dirnameInput" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
|
||||||
<input type="text" class="form-control" id="dirnameInput" name="dirname" required autofocus>
|
|
||||||
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Pass the in-dir value from the tree button to the modal hidden fields
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var newFileModal = document.getElementById('newFileModal');
|
|
||||||
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) {
|
|
||||||
newFileModal.addEventListener('show.bs.modal', function (event) {
|
|
||||||
var trigger = event.relatedTarget;
|
|
||||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
|
||||||
var hidden = document.getElementById('newFileDir');
|
|
||||||
if (hidden) hidden.value = inDir || '';
|
|
||||||
// Show the in-dir prefix as a hint in the placeholder
|
|
||||||
var input = document.getElementById('newFilenameInput');
|
|
||||||
if (input) {
|
|
||||||
input.value = '';
|
|
||||||
input.placeholder = inDir ? inDir + '/naam' : 'Bijv. nl.pagina of blog/nl.post';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (newDirModal) {
|
|
||||||
newDirModal.addEventListener('show.bs.modal', function (event) {
|
|
||||||
var trigger = event.relatedTarget;
|
|
||||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
|
||||||
var hidden = document.getElementById('newDirInDir');
|
|
||||||
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)
|
|
||||||
var layoutSelect = document.getElementById('layout');
|
|
||||||
if (layoutSelect) {
|
|
||||||
layoutSelect.addEventListener('change', function () {
|
|
||||||
var newLayout = this.value;
|
|
||||||
var cm = window.codeMirrorEditor;
|
|
||||||
var editor = document.getElementById('editor-textarea');
|
|
||||||
var content = cm ? cm.getValue() : (editor ? editor.value : '');
|
|
||||||
var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
||||||
if (fmMatch) {
|
|
||||||
var frontmatter = fmMatch[1];
|
|
||||||
if (/^layout:\s*.+$/m.test(frontmatter)) {
|
|
||||||
frontmatter = frontmatter.replace(/^layout:\s*.+$/m, 'layout: ' + newLayout);
|
|
||||||
} else {
|
|
||||||
frontmatter = 'layout: ' + newLayout + '\n' + frontmatter;
|
|
||||||
}
|
|
||||||
content = content.replace(/^---\n([\s\S]*?)\n---/, '---\n' + frontmatter + '\n---');
|
|
||||||
} else {
|
|
||||||
content = '---\nlayout: ' + newLayout + '\n---\n\n' + content;
|
|
||||||
}
|
|
||||||
if (cm) { cm.setValue(content); } else if (editor) { editor.value = content; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,45 +1,30 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
{% block title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{% if isDir %}{{ ta.folder|default('Map') }}{% else %}{{ ta.file|default('Bestand') }}{% endif %} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> {% if isDir %}{{ ta.folder|default('Map') }}{% else %}{{ ta.file|default('Bestand') }}{% endif %} {{ 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">
|
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/content-file-move?file={{ relFile|url_encode }}" class="card shadow-sm">
|
<form method="post" class="card shadow-sm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<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 / hernoem') }} <strong>{{ itemName }}{{ itemExt }}</strong>
|
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong> {{ ta.move_to|default('naar:') }}
|
||||||
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="new_name" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }}</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 class="mb-3">
|
|
||||||
<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 == itemDir ? 'selected' : '' }}>{{ directory == '' ? '(content root)' : directory }}</option>
|
<option value="{{ directory }}">{{ 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.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content?dir={{ itemDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-file-earmark-text"></i> {{ ta.content|default('Content') }}</h2>
|
<h2><i class="bi bi-file-earmark-text"></i> {{ ta.content|default('Content') }}</h2>
|
||||||
<div>
|
<div>
|
||||||
<a href="/admin/content" class="btn btn-outline-primary btn-sm me-1" title="{{ ta.tree_view|default('Boom weergave') }}">
|
|
||||||
<i class="bi bi-diagram-3"></i> {{ ta.tree_view|default('Boom weergave') }}
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
<button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -27,7 +24,7 @@
|
|||||||
<div class="collapse mb-4" id="uploadForm">
|
<div class="collapse mb-4" id="uploadForm">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="/admin/content-list?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
<form method="POST" action="/admin/content?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="file" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
<label for="file" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
||||||
@@ -46,7 +43,7 @@
|
|||||||
{% set parentDir = subdir|split('/')|slice(0, -1)|join('/') %}
|
{% set parentDir = subdir|split('/')|slice(0, -1)|join('/') %}
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="/admin/content-list"><i class="bi bi-house"></i></a></li>
|
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-house"></i></a></li>
|
||||||
{% set crumbPath = '' %}
|
{% set crumbPath = '' %}
|
||||||
{% for crumb in subdir|split('/') %}
|
{% for crumb in subdir|split('/') %}
|
||||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ crumb : crumb %}
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ crumb : crumb %}
|
||||||
@@ -54,7 +51,7 @@
|
|||||||
{% if crumbPath == subdir %}
|
{% if crumbPath == subdir %}
|
||||||
{{ crumb }}
|
{{ crumb }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/content-list?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
<a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -89,7 +86,7 @@
|
|||||||
<tr data-name="{{ item.name|lower }}">
|
<tr data-name="{{ item.name|lower }}">
|
||||||
<td>
|
<td>
|
||||||
{% if item.is_dir %}
|
{% if item.is_dir %}
|
||||||
<a href="/admin/content-list?dir={{ item.path|url_encode }}">
|
<a href="/admin/content?dir={{ item.path|url_encode }}">
|
||||||
<i class="bi bi-folder-fill text-warning"></i> {{ item.name }}
|
<i class="bi bi-folder-fill text-warning"></i> {{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -2,10 +2,99 @@
|
|||||||
|
|
||||||
{% 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">
|
||||||
{% include 'pages/_editor-styles.twig' %}
|
<style>
|
||||||
|
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
|
||||||
|
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
|
||||||
|
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||||
|
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||||
|
.plugin-tree li { padding: 0; position: relative; }
|
||||||
|
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
|
||||||
|
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||||
|
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||||
|
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||||
|
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
|
||||||
|
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||||
|
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
|
||||||
|
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||||
|
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* Move and delete buttons: always visible, right-aligned, normal icon size */
|
||||||
|
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
|
||||||
|
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
||||||
|
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
|
||||||
|
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
|
||||||
|
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
|
||||||
|
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.plugin-editor-layout { flex-direction: column; }
|
||||||
|
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 260px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -45,29 +134,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-layout">
|
<div class="plugin-editor-layout">
|
||||||
{# File tree sidebar (shared partial) #}
|
{# File tree sidebar #}
|
||||||
{% include 'pages/_file-tree.twig' with {
|
<div class="plugin-file-tree">
|
||||||
'files': files,
|
<div class="card shadow-sm h-100">
|
||||||
'relFile': relFile,
|
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||||
'editableExts': editableExts,
|
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.plugin_files|default('Plugin bestanden') }}</span>
|
||||||
'csrf_token': csrf_token,
|
</div>
|
||||||
'treeIdPrefix': 'plugin-tree',
|
<div class="card-body p-2" id="pluginFileTree">
|
||||||
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
{% if files is empty %}
|
||||||
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
<div class="small text-muted p-2">{{ ta.plugin_no_files|default('Geen bestanden gevonden.') }}</div>
|
||||||
'treeRouteBase': '/admin/plugins-edit',
|
{% else %}
|
||||||
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
{{ tree_macros.tree(files, pluginName, relFile, editableExts, csrf_token) }}
|
||||||
'treeDeleteRoute': '/admin/plugins-file-delete',
|
{% endif %}
|
||||||
'treeDeleteName': 'plugin',
|
</div>
|
||||||
'treeDeleteValue': pluginName,
|
</div>
|
||||||
'treeMoveRoute': '/admin/plugins-file-move',
|
</div>
|
||||||
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
|
||||||
'treeDirActions': false,
|
|
||||||
'treeHideActionsFor': [],
|
|
||||||
} %}
|
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="editor-main">
|
<div class="plugin-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">
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
|
||||||
|
|
||||||
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
|
||||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
|
||||||
{% include 'pages/_editor-styles.twig' %}
|
|
||||||
<style>
|
|
||||||
.theme-scss-status { font-size: .75rem; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
||||||
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }}</h2>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
{% if hasScss %}
|
|
||||||
<form method="POST" action="/admin/theme-scss" class="d-inline" onsubmit="return confirm('{{ ta.compile_scss_confirm|default('SCSS compileren? Dit overschrijft assets/css_compiled/theme.css.') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
|
||||||
<button type="submit" class="btn btn-outline-success btn-sm" title="{{ ta.compile_scss|default('SCSS compileren') }}">
|
|
||||||
<i class="bi bi-palette"></i> {{ ta.compile_scss|default('SCSS compileren') }}
|
|
||||||
{% if scssCompiled %}<span class="badge bg-success ms-1">{{ ta.scss_ok|default('up-to-date') }}</span>{% else %}<span class="badge bg-warning text-dark ms-1">{{ ta.scss_stale|default('verouderd') }}</span>{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#themeUploadForm">
|
|
||||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
|
||||||
<i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file|default('Nieuw bestand') }}
|
|
||||||
</button>
|
|
||||||
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Upload form (collapsed by default) #}
|
|
||||||
<div class="collapse mb-3" id="themeUploadForm">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/admin/theme-file-upload" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
|
||||||
<input type="hidden" name="dir" value="" id="themeUploadDir">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="themeUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
|
||||||
<input type="file" class="form-control" id="themeUploadFile" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav,.css,.scss,.js,.json,.html,.md,.twig,.woff,.woff2,.ttf">
|
|
||||||
<small class="form-text text-muted">{{ ta.theme_upload_help|default('Bestanden worden geüpload naar assets/ van dit thema. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts.') }}</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-layout">
|
|
||||||
{# File tree sidebar (shared partial) #}
|
|
||||||
{% include 'pages/_file-tree.twig' with {
|
|
||||||
'files': files,
|
|
||||||
'relFile': relFile,
|
|
||||||
'editableExts': editableExts,
|
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'treeIdPrefix': 'theme-tree',
|
|
||||||
'treeHeader': ta.theme_files|default('Thema bestanden'),
|
|
||||||
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
|
|
||||||
'treeRouteBase': '/admin/theme-edit',
|
|
||||||
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
|
|
||||||
'treeDeleteRoute': '/admin/theme-file-delete',
|
|
||||||
'treeDeleteName': 'theme',
|
|
||||||
'treeDeleteValue': themeName,
|
|
||||||
'treeMoveRoute': '/admin/theme-file-move',
|
|
||||||
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
|
||||||
'treeDirActions': false,
|
|
||||||
'treeHideActionsFor': ['theme.json'],
|
|
||||||
} %}
|
|
||||||
|
|
||||||
{# Editor main panel #}
|
|
||||||
<div class="editor-main">
|
|
||||||
{% if relFile %}
|
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
|
||||||
<ol class="breadcrumb mb-0">
|
|
||||||
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> {{ themeName }}</li>
|
|
||||||
{% set crumbPath = '' %}
|
|
||||||
{% set parts = relFile|split('/') %}
|
|
||||||
{% for part in parts %}
|
|
||||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
|
||||||
{% if loop.last %}
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if isEditable %}
|
|
||||||
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ relFile|url_encode }}" id="editor-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
|
||||||
</button>
|
|
||||||
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
|
|
||||||
{% if relFile %}
|
|
||||||
{{ ta.theme_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
|
||||||
{% else %}
|
|
||||||
{{ ta.theme_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# New file modal #}
|
|
||||||
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="action" value="new_file">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="newFilenameInput" class="form-label">{{ ta.theme_file_path|default('Bestandspad binnen thema') }}</label>
|
|
||||||
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. partials/header.twig of assets/scss/_variables.scss" required autofocus>
|
|
||||||
<div class="form-text">{{ ta.theme_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. Toegestaan: twig, json, scss, css, js, html, md, php.') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
|
||||||
|
|
||||||
{% block title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
||||||
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
|
|
||||||
<a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/theme-file-move?theme={{ themeName|url_encode }}&file={{ relFile|url_encode }}" class="card shadow-sm">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="theme" value="{{ themeName }}">
|
|
||||||
<input type="hidden" name="file" value="{{ relFile }}">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
|
|
||||||
{% if itemDir %}<span class="text-muted">({{ themeName }}/{{ itemDir }})</span>{% else %}<span class="text-muted">({{ themeName }}/)</span>{% endif %}
|
|
||||||
{{ ta.move_to|default('naar:') }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">({{ themeName }}/)</small></label>
|
|
||||||
<select class="form-select" id="destination" name="destination" required>
|
|
||||||
{% for directory in directories %}
|
|
||||||
<option value="{{ directory }}">{{ directory == '' ? '(thema root)' : directory }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-white">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
|
||||||
</button>
|
|
||||||
<a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -13,16 +13,6 @@
|
|||||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||||
<small class="form-text text-muted">{{ ta.theme_name_help|default('Alleen letters, cijfers, underscores en streepjes.') }}</small>
|
<small class="form-text text-muted">{{ ta.theme_name_help|default('Alleen letters, cijfers, underscores en streepjes.') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="base_theme" class="form-label">{{ ta.theme_base|default('Basis thema') }}</label>
|
|
||||||
<select class="form-select" id="base_theme" name="base_theme">
|
|
||||||
<option value="">{{ ta.theme_base_blank|default('(lege structuur)') }}</option>
|
|
||||||
{% for name in base_themes %}
|
|
||||||
<option value="{{ name }}">{{ name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<small class="form-text text-muted">{{ ta.theme_base_help|default('Kies een bestaand thema als basis om te kopiëren, of start met een lege uniforme structuur. css_compiled/ wordt niet gekopieerd.') }}</small>
|
|
||||||
</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">
|
||||||
|
|||||||
@@ -5,78 +5,53 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-palette"></i> {{ ta.themes|default('Thema\'s') }}</h2>
|
<h2><i class="bi bi-palette"></i> {{ ta.themes|default('Thema\'s') }}</h2>
|
||||||
<a href="/admin/theme-new" class="btn btn-primary btn-sm">
|
<div>
|
||||||
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" name="activate_default" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.activate_default|default('Activeer Default') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="/admin/theme-new" class="btn btn-outline-primary btn-sm ms-2">
|
||||||
<i class="bi bi-plus-lg"></i> {{ ta.new_theme|default('Nieuw thema') }}
|
<i class="bi bi-plus-lg"></i> {{ ta.new_theme|default('Nieuw thema') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
{% for theme in themes %}
|
{% for theme in themes %}
|
||||||
<div class="col-md-6 col-lg-4">
|
<div class="col-md-6 col-lg-4">
|
||||||
<div class="card shadow-sm h-100 {{ theme.active ? 'border-primary border-2' : '' }}">
|
<div class="card shadow-sm h-100 {{ theme.active ? 'border-primary border-2' : '' }}">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="fw-bold">
|
<span class="fw-bold">{{ theme.title|default(theme.name) }}</span>
|
||||||
{% if theme.protected %}<i class="bi bi-shield-check text-primary" title="{{ ta.theme_default_protected|default('Default thema - kan niet verwijderd worden') }}"></i>{% endif %}
|
|
||||||
{{ theme.title|default(theme.name) }}
|
|
||||||
</span>
|
|
||||||
<div class="d-flex gap-1 flex-wrap">
|
|
||||||
{% if theme.active %}
|
{% if theme.active %}
|
||||||
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if theme.has_scss %}
|
|
||||||
{% if theme.scss_compiled %}
|
|
||||||
<span class="badge bg-success" title="{{ ta.scss_compiled_ok|default('SCSS is gecompileerd en up-to-date') }}">{{ ta.scss_ok|default('SCSS ok') }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-warning text-dark" title="{{ ta.scss_needs_compile|default('SCSS source nieuwer dan gecompileerde CSS') }}">{{ ta.scss_stale|default('SCSS verouderd') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small mb-1">{{ ta.name_label|default('Naam: ') }}<code>{{ theme.name }}</code></p>
|
<p class="text-muted small">{{ ta.name_label|default('Naam: ') }}{{ theme.name }}</p>
|
||||||
{% if theme.config.default_template %}
|
{% if theme.default_layout %}
|
||||||
<p class="text-muted small mb-1">{{ ta.default_layout_label|default('Default layout: ') }}{{ theme.config.default_template }}</p>
|
<p class="text-muted small">{{ ta.default_layout_label|default('Default layout: ') }}{{ theme.default_layout }}</p>
|
||||||
{% endif %}
|
|
||||||
{% if theme.scss_mtime %}
|
|
||||||
<p class="text-muted small mb-1"><i class="bi bi-clock"></i> SCSS: {{ theme.scss_mtime }}{% if theme.css_mtime %} → CSS: {{ theme.css_mtime }}{% endif %}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if theme.template %}
|
|
||||||
<p class="text-muted small mb-0">{{ ta.theme_layouts|default('Layouts: ') }}{{ theme.template|keys|join(', ') }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white">
|
<div class="card-footer bg-white">
|
||||||
<div class="btn-group w-100" role="group" aria-label="{{ ta.theme_actions|default('Thema acties') }}">
|
|
||||||
<a href="/admin/theme-edit?theme={{ theme.name|url_encode }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit|default('Bewerken') }}" aria-label="{{ ta.edit|default('Bewerken') }}">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
{% if theme.has_scss %}
|
|
||||||
<form method="POST" action="/admin/theme-scss" class="d-inline" onsubmit="return confirm('{{ ta.compile_scss_confirm|default('SCSS compileren? Dit overschrijft assets/css_compiled/theme.css.') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-success" title="{{ ta.compile_scss|default('SCSS compileren') }}" aria-label="{{ ta.compile_scss|default('SCSS compileren') }}">
|
|
||||||
<i class="bi bi-palette"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if not theme.active %}
|
{% if not theme.active %}
|
||||||
<form method="POST" action="/admin/theme-activate" class="d-inline">
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
<input type="hidden" name="activate" value="{{ theme.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="{{ ta.activate|default('Activeren') }}" aria-label="{{ ta.activate|default('Activeren') }}">
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-check-lg"></i>
|
<i class="bi bi-check-lg"></i> {{ ta.activate|default('Activeren') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not theme.protected and not theme.active %}
|
<form method="POST" action="/admin/theme" class="d-inline">
|
||||||
<form method="POST" action="/admin/theme-delete" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_theme|default('Weet je zeker dat je dit thema wilt verwijderen? Alle bestanden in het thema gaan verloren.') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="compile_scss" value="1">
|
||||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-palette"></i> {{ ta.compile_scss|default('SCSS compileren') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+58
-145
@@ -90,9 +90,8 @@ 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; keep the dot so
|
// Remove any characters that could be used for XSS
|
||||||
// content file extensions (md/php/html) survive in the URL.
|
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page);
|
||||||
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
|
|
||||||
return $sanitized ?: 'invalid-page';
|
return $sanitized ?: 'invalid-page';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +330,6 @@ class CodePressCMS {
|
|||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
|
||||||
if ($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)
|
||||||
@@ -360,13 +358,12 @@ 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);
|
||||||
// Keep the extension in the URL so files sharing the same name
|
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
|
||||||
// but different type each keep their own viewable link.
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'type' => 'file',
|
'type' => 'file',
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'path' => $relativePath,
|
'path' => $pathWithoutExt,
|
||||||
'url' => $this->buildUrl($relativePath)
|
'url' => $this->buildUrl($pathWithoutExt)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,8 +397,7 @@ class CodePressCMS {
|
|||||||
$items = scandir($dir);
|
$items = scandir($dir);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
if ($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;
|
||||||
@@ -483,13 +479,8 @@ 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);
|
||||||
// Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
|
// Only remove file extension at the end, not all dots
|
||||||
$explicitExt = null;
|
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page);
|
||||||
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;
|
||||||
|
|
||||||
@@ -507,32 +498,43 @@ class CodePressCMS {
|
|||||||
|
|
||||||
$actualFilePath = null;
|
$actualFilePath = null;
|
||||||
|
|
||||||
// If the URL has an explicit extension, try that exact file first
|
// Check for exact file matches if no directory found
|
||||||
if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
|
if (file_exists($filePath . '.md')) {
|
||||||
$actualFilePath = $filePath . '.' . $explicitExt;
|
$actualFilePath = $filePath . '.md';
|
||||||
$result = $this->resolveContentByType($filePath, $explicitExt);
|
|
||||||
if ($result !== null) {
|
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
|
||||||
$actualFilePath = $result['path'];
|
} elseif (file_exists($filePath . '.php')) {
|
||||||
$result = $result['content'];
|
$actualFilePath = $filePath . '.php';
|
||||||
}
|
$result = $this->parsePHP($actualFilePath);
|
||||||
}
|
} elseif (file_exists($filePath . '.html')) {
|
||||||
// No explicit extension: serve the first matching file (md > php > html)
|
$actualFilePath = $filePath . '.html';
|
||||||
if (!isset($result)) {
|
$result = $this->parseHTML(file_get_contents($actualFilePath));
|
||||||
$resolved = $this->resolveContentByType($filePath);
|
} elseif (file_exists($filePath)) {
|
||||||
if ($resolved !== null) {
|
$actualFilePath = $filePath;
|
||||||
$actualFilePath = $resolved['path'];
|
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||||
$result = $resolved['content'];
|
if ($extension === 'md') {
|
||||||
|
$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;
|
|
||||||
$resolved = $this->resolveContentByType($langBase, $explicitExt);
|
if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) {
|
||||||
if ($resolved !== null) {
|
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md';
|
||||||
$actualFilePath = $resolved['path'];
|
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
|
||||||
$result = $resolved['content'];
|
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) {
|
||||||
|
$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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,40 +551,6 @@ 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
|
||||||
*
|
*
|
||||||
@@ -866,11 +834,7 @@ class CodePressCMS {
|
|||||||
private function autoLinkPageTitles($content, $excludeTitle = '') {
|
private function autoLinkPageTitles($content, $excludeTitle = '') {
|
||||||
$pages = $this->getAllPageTitles();
|
$pages = $this->getAllPageTitles();
|
||||||
|
|
||||||
foreach ($pages as $page) {
|
foreach ($pages as $pagePath => $pageTitle) {
|
||||||
$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;
|
||||||
}
|
}
|
||||||
@@ -897,10 +861,9 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all content entries (mappen + bestanden) from the content directory.
|
* Get all page titles from content directory
|
||||||
*
|
*
|
||||||
* @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...]
|
* @return array Associative array of page paths to titles
|
||||||
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
|
||||||
*/
|
*/
|
||||||
public function getAllPageTitles() {
|
public function getAllPageTitles() {
|
||||||
$pages = [];
|
$pages = [];
|
||||||
@@ -909,13 +872,7 @@ class CodePressCMS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively scan for content entries in a directory.
|
* Recursively scan for page titles in 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
|
||||||
@@ -929,30 +886,25 @@ class CodePressCMS {
|
|||||||
sort($items);
|
sort($items);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
if ($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));
|
||||||
$title = $this->formatDisplayName($filename);
|
$cleanName = $this->formatDisplayName($filename);
|
||||||
|
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
|
||||||
|
$pages[$pagePath] = $cleanName;
|
||||||
}
|
}
|
||||||
$pages[] = [
|
|
||||||
'path' => $relativePath,
|
|
||||||
'title' => $title,
|
|
||||||
'type' => pathinfo($item, PATHINFO_EXTENSION),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1047,15 +999,9 @@ 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;
|
||||||
$returnedContent = include $filePath;
|
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);
|
||||||
|
|
||||||
@@ -1283,8 +1229,7 @@ class CodePressCMS {
|
|||||||
$allItems = [];
|
$allItems = [];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
|
if ($item[0] === '.') continue;
|
||||||
if ($item[0] === '.' || $item[0] === '-') continue;
|
|
||||||
|
|
||||||
$itemPath = $dirPath . '/' . $item;
|
$itemPath = $dirPath . '/' . $item;
|
||||||
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
|
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
|
||||||
@@ -1298,16 +1243,15 @@ 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' => $relativePath,
|
'path' => $pathWithoutExt,
|
||||||
'url' => $this->buildUrl($relativePath),
|
'url' => $this->buildUrl($pathWithoutExt),
|
||||||
'icon' => $icon,
|
'icon' => $icon,
|
||||||
'type' => 'file'
|
'type' => 'file'
|
||||||
];
|
];
|
||||||
@@ -1858,37 +1802,6 @@ class CodePressCMS {
|
|||||||
|
|
||||||
private function processContent(string $content): string
|
private function processContent(string $content): string
|
||||||
{
|
{
|
||||||
// Legacy rewrite for the old -/assets/ shortcut
|
return str_replace('-/assets/', '/-assets/', $content);
|
||||||
$content = str_replace('-/assets/', '/-assets/', $content);
|
|
||||||
|
|
||||||
// Rewrite <img src="..."> and <a href="..."> that point to files in the
|
|
||||||
// content directory so they are served via the /-media/ endpoint. The
|
|
||||||
// content/ directory lives outside the webroot, so raw URLs like
|
|
||||||
// "test.svg", "/content/test.svg" or "sub/test.svg" would otherwise 404.
|
|
||||||
//
|
|
||||||
// Skipped (left untouched):
|
|
||||||
// - absolute URLs (http://, https://, //)
|
|
||||||
// - already rewritten /-media/ and /-assets/ URLs
|
|
||||||
// - theme/plugin asset URLs (/themes/..., /plugins/..., /admin/...)
|
|
||||||
// - data: and mailto: URLs
|
|
||||||
// - fragment-only URLs (#anchor)
|
|
||||||
$content = preg_replace_callback(
|
|
||||||
'~(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*")([^"]+)(")~i',
|
|
||||||
function ($m) {
|
|
||||||
$url = $m[2];
|
|
||||||
// Leave absolute, already-rewritten, asset, data: and anchor URLs alone
|
|
||||||
if (preg_match('~^(?:[a-z][a-z0-9+.\-]*:|//|/themes/|/plugins/|/admin/|/-media/|/-assets/|data:|mailto:)~i', $url)) {
|
|
||||||
return $m[0];
|
|
||||||
}
|
|
||||||
// Strip a leading /content/ prefix if present, then prefix with /-media/
|
|
||||||
$clean = preg_replace('~^/content/~', '', $url);
|
|
||||||
// Strip a leading slash so it becomes a clean /-media/<path>
|
|
||||||
$clean = ltrim($clean, '/');
|
|
||||||
return $m[1] . '/-media/' . $clean . $m[3];
|
|
||||||
},
|
|
||||||
$content
|
|
||||||
);
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ class ContentAPI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
* Get all pages as a flat array of path => title pairs
|
||||||
*
|
*
|
||||||
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons']
|
||||||
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
@@ -30,32 +29,18 @@ 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
|
||||||
*
|
*
|
||||||
* De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
|
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1')
|
||||||
* 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;
|
||||||
|
|
||||||
// Probeer de gevraagde extensie eerst, dan de standaard volgorde
|
$extensions = ['md', 'php', 'html'];
|
||||||
$order = ['md', 'php', 'html'];
|
|
||||||
if ($preferredExt !== null) {
|
|
||||||
$order = array_unique(array_merge([$preferredExt], $order));
|
|
||||||
}
|
|
||||||
$actualPath = null;
|
$actualPath = null;
|
||||||
foreach ($order as $ext) {
|
foreach ($extensions as $ext) {
|
||||||
if (file_exists($filePath . '.' . $ext)) {
|
if (file_exists($filePath . '.' . $ext)) {
|
||||||
$actualPath = $filePath . '.' . $ext;
|
$actualPath = $filePath . '.' . $ext;
|
||||||
break;
|
break;
|
||||||
@@ -156,13 +141,14 @@ 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, al dan niet met extensie
|
* @param string $path Page path without extension
|
||||||
* @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'];
|
||||||
$basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
|
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
||||||
|
$basePath = $contentDir . '/' . $path;
|
||||||
|
|
||||||
return file_exists($basePath . '.md')
|
return file_exists($basePath . '.md')
|
||||||
|| file_exists($basePath . '.php')
|
|| file_exists($basePath . '.php')
|
||||||
|
|||||||
@@ -224,9 +224,7 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
* Get all pages with their metadata
|
||||||
*
|
|
||||||
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
# CodePress CMS v2.6.2 — Release Notes
|
|
||||||
|
|
||||||
**Release datum:** 2026-08-19
|
|
||||||
**Codename:** Lyra
|
|
||||||
**Status:** stable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Samenvatting
|
|
||||||
|
|
||||||
Deze release brengt de **thema-editor** en de **content-editor** op hetzelfde niveau als de plugin-editor. Thema's en content zijn nu volledig bewerkbaar in de admin met een bestandsbrowser zijbalk, uploaden/verwijderen/verplaatsen, en SCSS-compile (thema's) of git/backup-integratie (content). Daarnaast is de bestandsscanner geünificeerd tot één herbruikbare `scanEditorFiles()` functie.
|
|
||||||
|
|
||||||
Beide grote features zijn geïmplementeerd als spiegel van de plugin-editor (`/admin/plugins-edit`), zodat de editor-ervaring consistent is voor plugins, thema's en content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nieuwe features
|
|
||||||
|
|
||||||
### 1. Thema editor (thema's net zo bewerkbaar maken als plugins)
|
|
||||||
|
|
||||||
De thema-beheer pagina toonde alleen thema-kaarten met activeren/compileer knoppen. Nu is elk thema volledig bewerkbaar via `/admin/theme-edit`.
|
|
||||||
|
|
||||||
#### Bestandsbrowser zijbalk
|
|
||||||
- Geneste, inklapbare boomstructuur van alle bestanden in de thema-map (`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`) inclusief `partials/` en `assets/` submappen
|
|
||||||
- `assets/css_compiled/` (runtime artefact, read-only) wordt verborgen in de boom
|
|
||||||
- `scanThemeFiles()` helper — recursieve boom-opbouw met path-traversal bescherming; skipt `css_compiled/`
|
|
||||||
- Mappen die het actieve bestand bevatten worden automatisch uitgeklapt
|
|
||||||
- Iconen per bestandstype (TWIG, PHP, JSON, MD, CSS, JS, HTML, mappen)
|
|
||||||
|
|
||||||
#### Nieuw bestand aanmaken
|
|
||||||
- "Nieuw bestand" knop + modal met pad-invoer (bijv. `partials/header.twig` of `assets/scss/_variables.scss`)
|
|
||||||
- Submappen worden automatisch aangemaakt
|
|
||||||
- Stub-content per extensie (`{% extends 'base.twig' %}` voor TWIG, `{\n}\n` voor JSON, enz.)
|
|
||||||
- Path-traversal bescherming: `../`/`..\\`/`./` stripping + `..`/`.` segment-guard + realpath prefix-check
|
|
||||||
|
|
||||||
#### Uploaden / Verwijderen / Verplaatsen
|
|
||||||
- Upload-knop + inklapbaar upload-formulier dat bestanden opslaat in `themes/<naam>/assets/`
|
|
||||||
- Toegestane extensies: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts
|
|
||||||
- Per-bestand prullenbak-icoon en verplaats-icoon in de bestandsboom
|
|
||||||
- `theme.json` is beschermd: kan bewerkt maar niet verwijderd of verplaatst worden
|
|
||||||
|
|
||||||
#### SCSS compileren vanuit de editor
|
|
||||||
- "SCSS compileren" knop bovenaan de editor (alleen als `assets/scss/theme.scss` bestaat)
|
|
||||||
- Toont compile-status: **up-to-date** (groen) of **verouderd** (geel)
|
|
||||||
- Forceert compilatie via `ThemeManager::compileCss(true)`
|
|
||||||
|
|
||||||
#### Thema overzicht + activeren + verwijderen
|
|
||||||
- `theme.twig` vernieuwd: bewerken/activeer/verwijder/compileer knoppen per thema-kaart
|
|
||||||
- SCSS-status badges per thema (up-to-date / verouderd)
|
|
||||||
- **Activeren**: zet `active_theme` in `config.json` (nieuwe route `theme-activate`)
|
|
||||||
- **Verwijderen**: alleen niet-actieve, niet-default thema's (nieuwe route `theme-delete`); default thema beschermd
|
|
||||||
- **Nieuw thema met basis-kopie**: kies een bestaand thema als basis bij aanmaken; `css_compiled/` wordt niet gekopieerd; de `title` in `theme.json` wordt overschreven met de nieuwe themanaam; `README.md` krijgt een nieuwe header
|
|
||||||
|
|
||||||
#### Uniforme thema-structuur
|
|
||||||
Nieuwe thema's die via de admin worden aangemaakt krijgen automatisch een uniforme structuur:
|
|
||||||
- `theme.json`, `base.twig`, `full_content.twig`, `partials/header.twig`, `partials/footer.twig`
|
|
||||||
- `assets/scss/theme.scss` (starter SCSS), `assets/css/`, `assets/js/`, `assets/img/`, `assets/fonts/`
|
|
||||||
- `README.md` per thema
|
|
||||||
|
|
||||||
#### Media invoegen in editor
|
|
||||||
- De media-modal ondersteunt nu ook thema-context: `/admin/media-list?theme=<naam>` scant `themes/<naam>/assets/`
|
|
||||||
- `_media-modal.twig` JS uitgebreid met `mediaThemeName` scope
|
|
||||||
|
|
||||||
### 2. Content editor (content consistent met plugins en thema's)
|
|
||||||
|
|
||||||
De content-beheer pagina (`/admin/content`) was een klassieke tabelweergave. Nu is er een额外的 content-editor `/admin/content-files` met dezelfde bestandsbrowser-zijbalk-ervaring als de plugin- en thema-editors. Beide weergaven werken naast elkaar en gebruiken dezelfde content-map.
|
|
||||||
|
|
||||||
#### Bestandsbrowser zijbalk
|
|
||||||
- Geneste, inklapbare boomstructuur van alle bestanden in `content/`
|
|
||||||
- `scanContentFiles()` helper — recursieve boom-opbouw met path-traversal bescherming; skipt `.bak/`, `.git/`, dotfiles
|
|
||||||
- Bewerkbare extensies: `.md`, `.php`, `.html` (consistent met bestaande `content-edit`)
|
|
||||||
- Per-map actie-knoppen: nieuw bestand, nieuwe map, hernoemen, verwijderen
|
|
||||||
|
|
||||||
#### Nieuw bestand / map aanmaken
|
|
||||||
- "Nieuw bestand" knop + modal met pad-invoer en extensie-keuze (Markdown/PHP/HTML)
|
|
||||||
- `in_dir` prefix ondersteuning: bestand kan direct in een submap worden aangemaakt vanuit de zijbalk
|
|
||||||
- Frontmatter met `layout`, `author_name`, `author_email`, `created` wordt automatisch gegenereerd
|
|
||||||
- "Nieuwe map" knop + modal; submappen worden automatisch aangemaakt
|
|
||||||
|
|
||||||
#### Uploaden / Verwijderen / Verplaatsen
|
|
||||||
- Upload-knop + inklapbaar upload-formulier dat bestanden opslaat in `content/` (of een submap)
|
|
||||||
- Toegestane extensies: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
|
||||||
- Per-bestand prullenbak-icoon en verplaats-icoon in de bestandsboom
|
|
||||||
- Nieuwe routes: `content-file-upload`, `content-file-delete`, `content-file-move`
|
|
||||||
|
|
||||||
#### Mappen beheer
|
|
||||||
- **Nieuwe map** (`content-dir-create-in`): per map in de zijbalk of bovenaan
|
|
||||||
- **Map hernoemen** (`content-dir-rename-in`): potlood-icoon per map
|
|
||||||
- **Map verwijderen** (`content-dir-delete-in`): prullenbak-icoon per map (alleen lege mappen; verborgen `.bak` bestanden genegeerd)
|
|
||||||
|
|
||||||
#### Layout selectie + plugins + frontmatter
|
|
||||||
- Layout dropdown uit `theme.json` template mapping (zoals bestaande `content-edit`)
|
|
||||||
- Plugin checkbox-groep voor `plugins:` frontmatter key
|
|
||||||
- Author metadata, `created` datum in frontmatter
|
|
||||||
- Live frontmatter-update bij layout/plugin wijziging (spiegel van `content-edit.twig` JS)
|
|
||||||
|
|
||||||
#### Git / Backup integratie (Fase 5)
|
|
||||||
Bovenaan de content-editor staan backup- en git-acties:
|
|
||||||
- **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
|
|
||||||
- Git status badge toont de huidige branch, of er niet-committed wijzigingen zijn (dirty/clean), en de laatste commit
|
|
||||||
|
|
||||||
### 3. Uniformiteit: één scanEditorFiles() voor alle editors
|
|
||||||
|
|
||||||
De drie nagenoeg identieke scan-functies (`scanPluginFiles`, `scanThemeFiles`, `scanContentFiles`) zijn geünificeerd tot één herbruikbare functie:
|
|
||||||
|
|
||||||
- `scanEditorFiles(string $dir, string $scope = 'plugin'): array` — hoofd-functie
|
|
||||||
- `scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = []): array` — recursieve helper
|
|
||||||
- Scope-specifieke skips via `$skipPaths` map (bijv. `['assets/css_compiled' => true]` voor themes)
|
|
||||||
- `scanPluginFiles`/`scanThemeFiles`/`scanContentFiles` behouden als dunne wrappers voor backwards-compatibiliteit
|
|
||||||
|
|
||||||
De file-upload/delete/move handler-families zijn per-scope behouden (plugin: protected-plugins check; theme: theme.json-bescherming; content: geen specifieke bescherming) — generalisatie zou een complexe configuratie-laag vereisen die de leesbaarheid niet verbetert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug fixes
|
|
||||||
|
|
||||||
- **Thema-naam niet overgenomen bij kopiëren** — bij het aanmaken van een nieuw thema met een basisthema werd de `title` uit het basisthema's `theme.json` gekopieerd; nu wordt de `title` overschreven met de nieuwe themanaam en krijgt `README.md` een nieuwe header
|
|
||||||
- **Mappen met `_` prefix zichtbaar in frontend navigatie** — mappen zoals `_drafts`, `_data`, `_images` werden in het menu getoond omdat `scanDirectory()` alleen `.` en `-` prefixen oversloeg. Nu worden ook `_`-prefix mappen overgeslagen in `scanDirectory()`, `searchInDirectory()` en `scanForPageTitles()` (consistente filtering)
|
|
||||||
- **Images in markdown niet weergegeven** — `` werd gerenderd als `<img src="test.svg">` (relatieve URL → 404) of `` als `<img src="/content/test.svg">` (content/ staat buiten webroot → 404). `processContent()` herschrijft nu lokale image/link URLs naar de `/-media/` endpoint die bestanden uit `content/` serveert met juiste MIME-type. Externe URLs (`http(s)://`), `/-media/`, `/-assets/`, `/themes/`, `/plugins/`, `/admin/`, `data:` en `mailto:` worden ongewijzigd gelaten. Ook de custom-grootte syntax `{:width=... height=...}` wordt correct herschreven.
|
|
||||||
|
|
||||||
## UX verbeteringen
|
|
||||||
|
|
||||||
- **`/admin/content` is nu de boom-editor** — de hoofdroute `/admin/content` toont nu de bestandsbrowser zijbalk structuur (zoals `plugin-file-tree`), consistent met de plugin- en thema-editors. De oude tabelweergave is verhuisd naar `/admin/content-list` en is bereikbaar via de "Lijst weergave" knop. Een "Boom weergave" knop in de tabelweergave keert terug naar de boom-editor. De `content-files` route redirect naar `/admin/content` (backward compat).
|
|
||||||
- **Image-grootte knop in editor-toolbar** — nieuwe knop "Afbeelding grootte" (expand-icoon) in de markdown editor-toolbar. Selecteer een `` image in de editor en klik op de knop om een breedte/hoogte-dialog te krijgen. De `{:width=... height=...}` syntax wordt aan de image toegevoegd of vervangen. Bestaande waarden worden getoond in de prompts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technische details
|
|
||||||
|
|
||||||
### Nieuwe routes
|
|
||||||
- `theme-edit` — thema bestandsbrowser editor
|
|
||||||
- `theme-file-upload` — bestanden uploaden naar thema assets/
|
|
||||||
- `theme-file-delete` — bestand verwijderen uit thema (theme.json beschermd)
|
|
||||||
- `theme-file-move` — bestand verplaatsen binnen thema (theme.json beschermd)
|
|
||||||
- `theme-activate` — thema activeren (active_theme in config.json)
|
|
||||||
- `theme-delete` — thema verwijderen (alleen niet-actief, niet-default)
|
|
||||||
- `theme-scss` — SCSS compileren voor thema
|
|
||||||
- `content-files` — content bestandsbrowser editor
|
|
||||||
- `content-file-upload` — bestanden uploaden naar content/
|
|
||||||
- `content-file-delete` — bestand verwijderen uit content
|
|
||||||
- `content-file-move` — bestand verplaatsen binnen content
|
|
||||||
- `content-dir-create-in` — map aanmaken in content vanuit editor
|
|
||||||
- `content-dir-rename-in` — map hernoemen in content vanuit editor
|
|
||||||
- `content-dir-delete-in` — map verwijderen in content vanuit editor (alleen leeg)
|
|
||||||
|
|
||||||
### Nieuwe bestanden
|
|
||||||
- `admin/theme/default/views/pages/theme-edit.twig` — thema editor met bestandsbrowser
|
|
||||||
- `admin/theme/default/views/pages/theme-move-form.twig` — thema bestand verplaatsen
|
|
||||||
- `admin/theme/default/views/pages/content-files.twig` — content editor met bestandsbrowser
|
|
||||||
- `admin/theme/default/views/pages/content-move-form.twig` — content bestand verplaatsen
|
|
||||||
- `admin/theme/default/views/pages/content-dir-rename-form.twig` — content map hernoemen
|
|
||||||
- `docs/release-notes/v2.6.2.md` — dit release-verslag
|
|
||||||
|
|
||||||
### Gewijzigde bestanden (core)
|
|
||||||
- `public/admin.php` — nieuwe routes + handlers (handleThemeEdit/FileUpload/Delete/Move/Activate/Delete/Scss, handleContentFiles/FileUpload/Delete/Move/DirCreateIn/DirRenameIn/DirDeleteIn, createUniformThemeStructure, copyDirRecursive, removeDirRecursive, scanEditorFiles + scanEditorFilesNode unified), handleTheme/handleThemeNew herschreven, handleMediaList uitgebreid met `?theme=` scope
|
|
||||||
- `admin/src/AdminAuth.php` — site-admin rechten voor nieuwe theme routes; content-manager rechten voor nieuwe content routes
|
|
||||||
- `admin/theme/default/views/pages/theme.twig` — bewerk/activeer/verwijder/compileer knoppen + SCSS-status badges
|
|
||||||
- `admin/theme/default/views/pages/theme-new.twig` — base-theme keuze dropdown
|
|
||||||
- `admin/theme/default/views/pages/_media-modal.twig` — `?theme=` scope support
|
|
||||||
- `admin/theme/default/assets/js/editor-toolbar.js` — `twig` → `htmlmixed` mode toegevoegd
|
|
||||||
- `cms/core/class/CodePressCMS.php` — `scanDirectory`/`searchInDirectory`/`scanForPageTitles` skippen nu `_` prefix; `processContent()` herschrijft lokale image/link URLs naar `/-media/`
|
|
||||||
- `language/{nl,en,de}/admin.php` — ~45 nieuwe vertaalstrings (theme-editor + content-editor)
|
|
||||||
- `guide/{nl,en}/admin-beheerder/thema-beheer.md` — volledig herschreven met thema-editor uitleg
|
|
||||||
- `guide/{nl,en}/admin-beheerder/content-beheer.md` — volledig herschreven met content-editor uitleg
|
|
||||||
- `TODO.md` — thema-editor en content-editor items aangevinkt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- **Pentest**: 30/30 tests geslaagd — 0 vulnerabilities
|
|
||||||
- **WCAG 2.1 AA accessibility**: 25/25 tests geslaagd — 100% compliance
|
|
||||||
- **PHP lint**: alle bestanden schoon (geen syntax errors)
|
|
||||||
- **curl tests**: alle nieuwe routes getest (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, map aanmaken/hernoemen/verwijderen, SCSS compileren, thema activeren/verwijderen, thema kopiëren); path-traversal pogingen (delete/new_file/upload) allemaal geblokkeerd; default/active theme bescherming geverifieerd; theme.json verwijderen/plaatsen geblokkeerd; image-URL rewriting geverifieerd (relatief, /content/, subdir, extern ongewijzigd); `_`-map niet in frontend navigatie
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bestanden gewijzigd sinds v2.6.1d
|
|
||||||
|
|
||||||
15 bestanden gewijzigd (core + admin theme + language + guide), ~2200 insertions, ~200 deletions.
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# 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,109 +1,32 @@
|
|||||||
# Content management
|
# 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.
|
## Managing files
|
||||||
|
|
||||||
## Content editor
|
- **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
|
||||||
|
|
||||||
### File browser sidebar
|
## Editor (content-edit)
|
||||||
- Shows the nested file tree of `content/`
|
|
||||||
- Hidden directories (`.bak`, `.git`) and dotfiles are skipped
|
|
||||||
- Click a file to open it in the CodeMirror editor
|
|
||||||
- Folders containing the active file are auto-expanded
|
|
||||||
- Per folder there are action buttons: new file, new folder, rename, delete
|
|
||||||
|
|
||||||
### Editable file types
|
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML)
|
||||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
|
- **Toolbar** for quickly inserting Markdown
|
||||||
|
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
|
||||||
|
|
||||||
All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
|
## Layout selection
|
||||||
|
|
||||||
#### PHP content files
|
On the content-edit 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.
|
||||||
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`:
|
## Plugins on pages
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
$pages = $api->getAllPages();
|
|
||||||
$config = $api->getConfig('site_title');
|
|
||||||
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend URLs and file names
|
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection
|
||||||
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:
|
- Choose which plugins appear in the sidebar
|
||||||
- `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
|
- **Order is adjustable** with up/down buttons
|
||||||
- `/nl/test.php` → opens `test.php` (explicit extension)
|
- The plugin order is stored in the frontmatter `plugins:` key
|
||||||
- `/nl/test.html` → opens `test.html` (explicit extension)
|
- Plugins are **hidden** when the chosen layout has no sidebar (e.g. `full_content`)
|
||||||
|
|
||||||
The preview link in the editor automatically uses the extension for non-`.md` files.
|
|
||||||
|
|
||||||
### Set image size (Markdown)
|
|
||||||
The markdown editor toolbar has an "Image size" button (expand icon):
|
|
||||||
1. Select an image in the editor in markdown format ``
|
|
||||||
2. Click the "Image size" button
|
|
||||||
3. Enter width and/or height (px or %, empty = don't set)
|
|
||||||
4. The `{:width=... height=...}` syntax is added to or replaced on the image
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```markdown
|
|
||||||
{:width="200" height="100"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Existing `{:width=...}` values are shown in the prompts. The size attributes are rendered as `width`/`height` HTML attributes on the `<img>` tag in the frontend.
|
|
||||||
|
|
||||||
### Images in content
|
|
||||||
Images in `content/` are served via the `/-media/` endpoint (content/ lives outside the webroot). Markdown syntax `` with local URLs (relative like `image.jpg`, or absolute like `/content/_images/image.jpg`) is automatically rewritten to `/-media/...`. External URLs (`https://...`) are left untouched.
|
|
||||||
|
|
||||||
### Create a new file
|
|
||||||
- Click **New file** (at the top or per folder in the sidebar)
|
|
||||||
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
|
|
||||||
- Choose the file type (Markdown/PHP/HTML)
|
|
||||||
- Subfolders are created automatically
|
|
||||||
- Frontmatter with `layout`, `created`, `edited` is auto-generated
|
|
||||||
|
|
||||||
### Upload a file
|
|
||||||
- Click **Upload** to upload files to `content/`
|
|
||||||
- Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
|
||||||
- Path-traversal protection: target dir must stay within `content/`
|
|
||||||
|
|
||||||
### Move / delete a file
|
|
||||||
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
|
|
||||||
- **Move**: choose a target folder from the dropdown listing all folders in content
|
|
||||||
- **Delete**: with confirmation
|
|
||||||
|
|
||||||
### Folder management
|
|
||||||
- **New folder**: per folder in the sidebar or at the top
|
|
||||||
- **Rename folder**: pencil icon per folder
|
|
||||||
- **Delete folder**: trash icon per folder (only empty folders)
|
|
||||||
|
|
||||||
### Layout selection
|
|
||||||
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
|
|
||||||
- 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 via the multiselect (use Ctrl/Cmd+click for multiple)
|
|
||||||
- The plugin selection is stored in the frontmatter `plugins:` key
|
|
||||||
|
|
||||||
### Backup integration
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Frontmatter
|
|
||||||
|
|
||||||
The editor shows the **Created** and **Edited** timestamps (read-only, automatically updated on every save). The `edited:` value is refreshed on each save.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
layout: left_sidebar
|
|
||||||
created: 2026-08-19 10:30:25
|
|
||||||
edited: 2026-08-20 14:22:01
|
|
||||||
plugins: HTMLBlock, Navigation
|
|
||||||
---
|
|
||||||
|
|
||||||
# Page title
|
|
||||||
|
|
||||||
Content...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
@@ -112,9 +35,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
created: 2026-08-19 10:30:25
|
plugins:
|
||||||
edited: 2026-08-20 14:22:01
|
- HTMLBlock
|
||||||
plugins: HTMLBlock, Navigation
|
- Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Page title
|
# Page title
|
||||||
|
|||||||
@@ -3,72 +3,15 @@
|
|||||||
## Managing themes
|
## Managing themes
|
||||||
|
|
||||||
1. Go to **Theme** in the admin menu
|
1. Go to **Theme** in the admin menu
|
||||||
2. **Edit** — Click the pencil icon to edit theme files (see [Theme editor](#theme-editor))
|
2. **Activate** — Choose the active theme (stored in `config.json`)
|
||||||
3. **Activate** — Click the checkmark to activate a theme (stored in `config.json`)
|
3. **Compile SCSS** — Process SCSS to CSS (forced)
|
||||||
4. **Compile SCSS** — Click the palette icon to force-compile SCSS to `assets/css_compiled/theme.css`
|
4. **New theme** — Create a custom theme via admin or manually
|
||||||
5. **Delete** — Trash icon (only non-active, non-default themes)
|
|
||||||
6. **New theme** — Create a custom theme, optionally based on an existing theme
|
|
||||||
|
|
||||||
## Theme status badges
|
|
||||||
|
|
||||||
In the theme overview you see per theme:
|
|
||||||
- **Active** (green) — this theme is selected in `config.json`
|
|
||||||
- **SCSS ok** (green) — `assets/css_compiled/theme.css` is newer than `assets/scss/theme.scss`
|
|
||||||
- **SCSS stale** (yellow) — SCSS source was changed after the last compile; click the palette icon to compile
|
|
||||||
|
|
||||||
## Theme editor
|
|
||||||
|
|
||||||
Via **Edit** (pencil icon) in the theme overview you open the theme editor (`/admin/theme-edit?theme=<name>`). It works the same as the plugin editor:
|
|
||||||
|
|
||||||
### File browser sidebar
|
|
||||||
- Shows the nested file tree of the theme
|
|
||||||
- `assets/css_compiled/` is hidden (runtime artefact, read-only)
|
|
||||||
- Click a file to open it in the CodeMirror editor
|
|
||||||
- Folders containing the active file are auto-expanded
|
|
||||||
|
|
||||||
### Editable file types
|
|
||||||
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
|
||||||
|
|
||||||
### Create a new file
|
|
||||||
- Click **New file**
|
|
||||||
- Enter a path within the theme (e.g. `partials/header.twig` or `assets/scss/_variables.scss`)
|
|
||||||
- Subfolders are created automatically
|
|
||||||
- Allowed: twig, json, scss, css, js, html, md, php
|
|
||||||
- A stub is auto-generated (e.g. `{% extends 'base.twig' %}` for `.twig`)
|
|
||||||
|
|
||||||
### Upload a file
|
|
||||||
- Click **Upload** to upload files to the theme's `assets/`
|
|
||||||
- Allowed: images, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts
|
|
||||||
- Path-traversal protection: target dir must stay within `assets/`
|
|
||||||
|
|
||||||
### Move / delete a file
|
|
||||||
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
|
|
||||||
- **Move**: choose a target folder from the dropdown listing all folders in the theme
|
|
||||||
- **Delete**: with confirmation; `theme.json` cannot be deleted
|
|
||||||
|
|
||||||
### Compile SCSS from the editor
|
|
||||||
- At the top of the editor there is a **Compile SCSS** button (only if `assets/scss/theme.scss` exists)
|
|
||||||
- Shows the compile status: **up-to-date** (green) or **stale** (yellow)
|
|
||||||
- Forces compilation via `ThemeManager::compileCss(true)`
|
|
||||||
|
|
||||||
### Insert media in the editor
|
|
||||||
- The media button in the editor toolbar opens the media modal
|
|
||||||
- In theme context it scans `themes/<name>/assets/` (via `/admin/media-list?theme=<name>`)
|
|
||||||
- Snippet format depends on file type: markdown → ``, html/php → `<img src=...>`, others → raw URL
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- All actions require a CSRF token
|
|
||||||
- Path-traversal protection via `realpath()` + prefix check on the theme dir
|
|
||||||
- `theme.json` can be edited but not deleted/moved
|
|
||||||
- Default theme can be edited but not deleted
|
|
||||||
- Active theme cannot be deleted (activate another theme first)
|
|
||||||
|
|
||||||
## Theme structure
|
## Theme structure
|
||||||
|
|
||||||
```
|
```
|
||||||
themes/default/
|
themes/default/
|
||||||
├── theme.json # { title, config.default_template, template: layout→.twig }
|
├── theme.json # { title, config.default_template, template: layout→.twig }
|
||||||
├── README.md # Theme documentation (per theme)
|
|
||||||
├── base.twig # Main layout (head, header, nav, breadcrumb, footer)
|
├── base.twig # Main layout (head, header, nav, breadcrumb, footer)
|
||||||
├── full_content.twig # Layout: full width
|
├── full_content.twig # Layout: full width
|
||||||
├── left_sidebar.twig # Layout: sidebar on the left
|
├── left_sidebar.twig # Layout: sidebar on the left
|
||||||
@@ -78,15 +21,13 @@ themes/default/
|
|||||||
├── partials/ # header.twig, navigation.twig, footer.twig
|
├── partials/ # header.twig, navigation.twig, footer.twig
|
||||||
└── assets/
|
└── assets/
|
||||||
├── scss/theme.scss # SCSS source (only CSS source — manual css/theme.css must not exist)
|
├── scss/theme.scss # SCSS source (only CSS source — manual css/theme.css must not exist)
|
||||||
├── css_compiled/ # Generated by scssphp (read-only, do not edit manually, hidden in editor)
|
├── css_compiled/ # Generated by scssphp (read-only, do not edit manually)
|
||||||
├── css/ # External CSS (bootstrap.min.css, bootstrap-icons.css, mobile.css)
|
├── css/ # External CSS (bootstrap.min.css, bootstrap-icons.css, mobile.css)
|
||||||
├── js/ # app.js, bootstrap.bundle.min.js
|
├── js/ # app.js, bootstrap.bundle.min.js
|
||||||
├── fonts/ # bootstrap-icons.woff, woff2
|
├── fonts/ # bootstrap-icons.woff, woff2
|
||||||
└── img/ # favicon, icon, world-map
|
└── img/ # favicon, icon, world-map
|
||||||
```
|
```
|
||||||
|
|
||||||
New themes created via admin automatically get this uniform structure (with `README.md`, `base.twig`, `full_content.twig`, `partials/header.twig`, `partials/footer.twig`, `assets/scss/theme.scss`, and all assets subfolders).
|
|
||||||
|
|
||||||
## theme.json
|
## theme.json
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -112,9 +53,9 @@ New themes created via admin automatically get this uniform structure (with `REA
|
|||||||
## SCSS compilation
|
## SCSS compilation
|
||||||
|
|
||||||
- `ThemeManager` compiles `assets/scss/theme.scss` at runtime to `assets/css_compiled/theme.css` via scssphp
|
- `ThemeManager` compiles `assets/scss/theme.scss` at runtime to `assets/css_compiled/theme.css` via scssphp
|
||||||
- `css_compiled/` is **read-only** — do not edit manually (hidden in the theme editor)
|
- `css_compiled/` is **read-only** — do not edit manually
|
||||||
- `assets/css/theme.css` must **not** exist; otherwise `ThemeManager::getCssUrl()` ignores the SCSS
|
- `assets/css/theme.css` must **not** exist; otherwise `ThemeManager::getCssUrl()` ignores the SCSS
|
||||||
- Force compilation via the **Compile SCSS** button in admin (or remove `assets/css_compiled/theme.css` and `.mtime`)
|
- After SCSS changes: remove `assets/css_compiled/theme.css` and `.mtime` to force recompilation
|
||||||
|
|
||||||
## Layouts
|
## Layouts
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,84 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
PHP content files (`.php`) run inside the CMS and have access to a safe,
|
The Content API is available in PHP content files (`.php`) and provides a safe, read-only interface to CMS data.
|
||||||
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.
|
|
||||||
|
|
||||||
## How to pass content to the Twig template
|
## Usage in PHP content files
|
||||||
|
|
||||||
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 */
|
||||||
?>
|
|
||||||
<h1>Hello <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
// Get all pages
|
||||||
<p>Welcome to my page.</p>
|
$allPages = $api->getAllPages();
|
||||||
|
// 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);
|
||||||
```
|
```
|
||||||
|
|
||||||
Example with return:
|
## Available methods
|
||||||
|
|
||||||
```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 content entries (folders + files) as a list of `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` for files, `folder` for directories.
|
- `getAllPages(): array` - All pages as `['path' => 'title']` pairs
|
||||||
- `getPage(string $path): ?array` — A specific page; returns `title`, `content`, `path`, `layout`, `metadata`. `$path` may include an extension.
|
- `getPage(string $path): ?array` - Specific page with `title`, `content`, `path`, `layout`, `metadata`
|
||||||
- `pageExists(string $path): bool` — Check whether a page exists
|
- `pageExists(string $path): bool` - Check if a page exists
|
||||||
- `getCurrentPageTitle(): string` — Title of the current page
|
- `getCurrentPageTitle(): string` - Title of current page
|
||||||
- `getCurrentPagePath(): string` — Path of the current page
|
- `getCurrentPagePath(): string` - Path of current page
|
||||||
- `isHomepage(): bool` — Whether the current page is the homepage
|
- `isHomepage(): bool` - Whether 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
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Build a URL for a page
|
||||||
|
|
||||||
### 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
|
- `getAvailableLanguages(): array` - Available languages (e.g. `['nl', 'en']`)
|
||||||
- `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
|
||||||
|
|
||||||
### Author
|
## Example: Show recent pages
|
||||||
|
|
||||||
- `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 */
|
||||||
$entries = $api->getAllPages();
|
$pages = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach ($entries as $entry): ?>
|
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
||||||
<?php if ($entry['type'] === 'folder') continue; // skip folders ?>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
||||||
<?= htmlspecialchars($entry['title']) ?>
|
<?= htmlspecialchars($title) ?>
|
||||||
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use a config value
|
## Example: Using config values
|
||||||
|
|
||||||
```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)): ?>
|
||||||
@@ -137,44 +89,6 @@ 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:
|
||||||
@@ -194,12 +108,12 @@ $this->api->createUrl('about-us');
|
|||||||
$this->api->translate('home');
|
$this->api->translate('home');
|
||||||
```
|
```
|
||||||
|
|
||||||
In addition, CMSAPI has:
|
Additionally, CMSAPI provides:
|
||||||
|
|
||||||
- `getCurrentPage(): array` — Full page data
|
- `getCurrentPage(): array` - Full page data
|
||||||
- `getCurrentPageUrl(): string` — URL of the current page
|
- `getCurrentPageUrl(): string` - URL of 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 a PHP file and capture output
|
- `executePhpFile(string $filePath): string` - Execute PHP file and capture output
|
||||||
- `getFileContent(string $filePath): string` — Get content from a PHP/HTML/Markdown file
|
- `getFileContent(string $filePath): string` - Get content from PHP/HTML/Markdown file
|
||||||
- `contentFileExists(string $filename): bool` — Check whether a file exists in the content directory
|
- `contentFileExists(string $filename): bool` - Check if file exists in content directory
|
||||||
@@ -1,109 +1,32 @@
|
|||||||
# Content beheer
|
# 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.
|
## Bestanden beheren
|
||||||
|
|
||||||
## Content editor
|
- **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
|
||||||
|
|
||||||
### Bestandsbrowser zijbalk
|
## Editor (content-edit)
|
||||||
- Toont de geneste bestandsboom van `content/`
|
|
||||||
- Verborgen mappen (`.bak`, `.git`) en dotfiles worden overgeslagen
|
|
||||||
- Klik op een bestand om het te openen in de CodeMirror editor
|
|
||||||
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
|
||||||
- Per map staan actie-knoppen: nieuw bestand, nieuwe map, hernoemen, verwijderen
|
|
||||||
|
|
||||||
### Bewerkbare bestandstypen
|
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML)
|
||||||
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
|
- **Toolbar** voor snel Markdown invoeren
|
||||||
|
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
|
||||||
|
|
||||||
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.
|
## Layout selectie
|
||||||
|
|
||||||
#### PHP content bestanden
|
Op de content-edit 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.
|
||||||
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`:
|
## Plugins op pagina's
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
$pages = $api->getAllPages();
|
|
||||||
$config = $api->getConfig('site_title');
|
|
||||||
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend URL's en bestandsnamen
|
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie
|
||||||
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:
|
- Kies welke plugins in de sidebar verschijnen
|
||||||
- `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
|
- **Volgorde aanpasbaar** met up/down knoppen
|
||||||
- `/nl/test.php` → opent `test.php` (expliciete extensie)
|
- De plugin volgorde wordt opgeslagen in de frontmatter `plugins:` key
|
||||||
- `/nl/test.html` → opent `test.html` (expliciete extensie)
|
- Plugins worden **verbergen** als de gekozen layout geen sidebar heeft (bijv. `full_content`)
|
||||||
|
|
||||||
De preview link in de editor gebruikt automatisch de extensie voor niet-`.md` bestanden.
|
|
||||||
|
|
||||||
### Image-grootte instellen (Markdown)
|
|
||||||
In de markdown editor-toolbar staat een "Afbeelding grootte" knop (expand-icoon):
|
|
||||||
1. Selecteer een afbeelding in de editor in markdown formaat ``
|
|
||||||
2. Klik op de "Afbeelding grootte" knop
|
|
||||||
3. Vul breedte en/of hoogte in (px of %, leeg = niet instellen)
|
|
||||||
4. De `{:width=... height=...}` syntax wordt aan de afbeelding toegevoegd of vervangen
|
|
||||||
|
|
||||||
Voorbeeld:
|
|
||||||
```markdown
|
|
||||||
{:width="200" height="100"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Bestaande `{:width=...}` waarden worden in de prompts getoond. De grootte-attributen worden in de frontend als `width`/`height` HTML-attributen op de `<img>` tag gezet.
|
|
||||||
|
|
||||||
### Images in content
|
|
||||||
Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staat buiten de webroot). De markdown syntax `` met lokale URLs (relatief zoals `image.jpg`, of absoluut zoals `/content/_images/image.jpg`) wordt automatisch herschreven naar `/-media/...`. Externe URLs (`https://...`) worden ongewijzigd gelaten.
|
|
||||||
|
|
||||||
### Nieuw bestand aanmaken
|
|
||||||
- Klik op **Nieuw bestand** (bovenaan of per map in de zijbalk)
|
|
||||||
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
|
|
||||||
- Kies het bestandstype (Markdown/PHP/HTML)
|
|
||||||
- Submappen worden automatisch aangemaakt
|
|
||||||
- Frontmatter met `layout`, `created`, `edited` wordt automatisch gegenereerd
|
|
||||||
|
|
||||||
### Bestand uploaden
|
|
||||||
- Klik op **Upload** om bestanden naar `content/` te uploaden
|
|
||||||
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
|
||||||
- Path-traversal bescherming: doelmap moet binnen `content/` blijven
|
|
||||||
|
|
||||||
### Bestand verplaatsen / verwijderen
|
|
||||||
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
|
|
||||||
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in content
|
|
||||||
- **Verwijderen**: met bevestiging
|
|
||||||
|
|
||||||
### Mappen beheer
|
|
||||||
- **Nieuwe map**: per map in de zijbalk of bovenaan
|
|
||||||
- **Map hernoemen**: potlood-icoon per map
|
|
||||||
- **Map verwijderen**: prullenbak-icoon per map (alleen lege mappen)
|
|
||||||
|
|
||||||
### Layout selectie
|
|
||||||
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
|
|
||||||
- Alleen actieve **content plugins** (uit `plugin.json` met `type: "content"`) verschijnen in de plugin multiselect; system plugins (zoals Statistics/Logs/Dashboard) zijn uitgesloten
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### Backup integratie
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Frontmatter
|
|
||||||
|
|
||||||
De editor toont de **Aangemaakt** en **Bewerkt** timestamps (read-only, automatisch bijgewerkt bij elke opslag). De `edited:` waarde wordt bij elke save vernieuwd.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
layout: left_sidebar
|
|
||||||
created: 2026-08-19 10:30:25
|
|
||||||
edited: 2026-08-20 14:22:01
|
|
||||||
plugins: HTMLBlock, Navigation
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pagina titel
|
|
||||||
|
|
||||||
Content...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
@@ -112,9 +35,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
created: 2026-08-19 10:30:25
|
plugins:
|
||||||
edited: 2026-08-20 14:22:01
|
- HTMLBlock
|
||||||
plugins: HTMLBlock, Navigation
|
- Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pagina titel
|
# Pagina titel
|
||||||
|
|||||||
@@ -3,73 +3,15 @@
|
|||||||
## Thema's beheren
|
## Thema's beheren
|
||||||
|
|
||||||
1. Ga naar **Thema** in admin menu
|
1. Ga naar **Thema** in admin menu
|
||||||
2. **Bewerken** — Klik op het potlood-icoon om themabestanden te bewerken (zie [Thema editor](#thema-editor))
|
2. **Activeren** — Kies actief thema (wordt opgeslagen in `config.json`)
|
||||||
3. **Activeren** — Klik op het vinkje om een thema te activeren (wordt opgeslagen in `config.json`)
|
3. **SCSS compileren** — Verwerk SCSS naar CSS (geforceerd)
|
||||||
4. **SCSS compileren** — Klik op het palet-icoon om SCSS geforceerd te compileren naar `assets/css_compiled/theme.css`
|
4. **Nieuw thema** — Eigen thema aanmaken via admin of handmatig
|
||||||
5. **Verwijderen** — Prullenbak-icoon (alleen niet-actieve, niet-default thema's)
|
|
||||||
6. **Nieuw thema** — Eigen thema aanmaken, optioneel gebaseerd op een bestaand thema
|
|
||||||
|
|
||||||
## Thema status badges
|
|
||||||
|
|
||||||
In het thema-overzicht zie je per thema:
|
|
||||||
- **Actief** (groen) — dit thema is geselecteerd in `config.json`
|
|
||||||
- **SCSS ok** (groen) — `assets/css_compiled/theme.css` is nieuwer dan `assets/scss/theme.scss`
|
|
||||||
- **SCSS verouderd** (geel) — SCSS source is gewijzigd na laatste compile; klik op het palet-icoon om te compileren
|
|
||||||
|
|
||||||
## Thema editor
|
|
||||||
|
|
||||||
Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor (`/admin/theme-edit?theme=<naam>`). Deze werkt hetzelfde als de plugin-editor:
|
|
||||||
|
|
||||||
### Bestandsbrowser zijbalk
|
|
||||||
- Toont de geneste bestandsboom van het thema
|
|
||||||
- `assets/css_compiled/` wordt verborgen (runtime artefact, read-only)
|
|
||||||
- Klik op een bestand om het te openen in de CodeMirror editor
|
|
||||||
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
|
||||||
|
|
||||||
### Bewerkbare bestandstypen
|
|
||||||
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
|
||||||
|
|
||||||
### Nieuw bestand aanmaken
|
|
||||||
- Klik op **Nieuw bestand**
|
|
||||||
- Geef een pad op binnen het thema (bijv. `partials/header.twig` of `assets/scss/_variables.scss`)
|
|
||||||
- Submappen worden automatisch aangemaakt
|
|
||||||
- Toegestaan: twig, json, scss, css, js, html, md, php
|
|
||||||
- Een stub wordt automatisch gegenereerd (bijv. `{% extends 'base.twig' %}` voor `.twig`)
|
|
||||||
|
|
||||||
### Bestand uploaden
|
|
||||||
- Klik op **Upload** om bestanden naar `assets/` van het thema te uploaden
|
|
||||||
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts
|
|
||||||
- Path-traversal bescherming: doelmap moet binnen `assets/` blijven
|
|
||||||
|
|
||||||
### Bestand verplaatsen / verwijderen
|
|
||||||
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
|
|
||||||
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in het thema
|
|
||||||
- **Verwijderen**: met bevestiging; `theme.json` kan niet verwijderd worden
|
|
||||||
|
|
||||||
### SCSS compileren vanuit de editor
|
|
||||||
- Bovenaan de editor staat een **SCSS compileren** knop (alleen als `assets/scss/theme.scss` bestaat)
|
|
||||||
- Toont de compile-status: **up-to-date** (groen) of **verouderd** (geel)
|
|
||||||
- Forceert compilatie via `ThemeManager::compileCss(true)`
|
|
||||||
|
|
||||||
### Media invoegen in editor
|
|
||||||
- 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>`)
|
|
||||||
- Snippet-formaat depends op bestandstype: markdown → ``, html/php → `<img src=...>`, andere → ruwe URL
|
|
||||||
|
|
||||||
### Beveiliging
|
|
||||||
- Alle acties vereisen CSRF token
|
|
||||||
- Path-traversal bescherming via `realpath()` + prefix-check op de thema-map
|
|
||||||
- `theme.json` kan bewerkt maar niet verwijderd/verplaatst worden
|
|
||||||
- Default thema kan bewerkt maar niet verwijderd worden
|
|
||||||
- Actief thema kan niet verwijderd worden (activeer eerst een ander thema)
|
|
||||||
|
|
||||||
## Thema structuur
|
## Thema structuur
|
||||||
|
|
||||||
```
|
```
|
||||||
themes/default/
|
themes/default/
|
||||||
├── theme.json # { title, config.default_template, template: layout→.twig }
|
├── theme.json # { title, config.default_template, template: layout→.twig }
|
||||||
├── README.md # Thema documentatie (per thema)
|
|
||||||
├── base.twig # Hoofd layout (head, header, nav, breadcrumb, footer)
|
├── base.twig # Hoofd layout (head, header, nav, breadcrumb, footer)
|
||||||
├── full_content.twig # Layout: volledige breedte
|
├── full_content.twig # Layout: volledige breedte
|
||||||
├── left_sidebar.twig # Layout: sidebar links
|
├── left_sidebar.twig # Layout: sidebar links
|
||||||
@@ -79,15 +21,13 @@ themes/default/
|
|||||||
├── partials/ # header.twig, navigation.twig, footer.twig
|
├── partials/ # header.twig, navigation.twig, footer.twig
|
||||||
└── assets/
|
└── assets/
|
||||||
├── scss/theme.scss # SCSS bron (enige CSS bron — handmatige css/theme.css mag niet bestaan)
|
├── scss/theme.scss # SCSS bron (enige CSS bron — handmatige css/theme.css mag niet bestaan)
|
||||||
├── css_compiled/ # Gegenereerd door scssphp (read-only, niet handmatig aanpassen, verborgen in editor)
|
├── css_compiled/ # Gegenereerd door scssphp (read-only, niet handmatig aanpassen)
|
||||||
├── css/ # Externe CSS (bootstrap.min.css, bootstrap-icons.css, mobile.css)
|
├── css/ # Externe CSS (bootstrap.min.css, bootstrap-icons.css, mobile.css)
|
||||||
├── js/ # app.js, bootstrap.bundle.min.js
|
├── js/ # app.js, bootstrap.bundle.min.js
|
||||||
├── fonts/ # bootstrap-icons.woff, woff2
|
├── fonts/ # bootstrap-icons.woff, woff2
|
||||||
└── img/ # favicon, icon, world-map
|
└── img/ # favicon, icon, world-map
|
||||||
```
|
```
|
||||||
|
|
||||||
Nieuwe thema's die via de admin worden aangemaakt krijgen automatisch deze uniforme structuur (met `README.md`, `base.twig`, `full_content.twig`, `partials/header.twig`, `partials/footer.twig`, `assets/scss/theme.scss`, en alle assets-submappen).
|
|
||||||
|
|
||||||
## theme.json
|
## theme.json
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -113,9 +53,9 @@ Nieuwe thema's die via de admin worden aangemaakt krijgen automatisch deze unifo
|
|||||||
## SCSS compilatie
|
## SCSS compilatie
|
||||||
|
|
||||||
- `ThemeManager` compileert `assets/scss/theme.scss` runtime naar `assets/css_compiled/theme.css` via scssphp
|
- `ThemeManager` compileert `assets/scss/theme.scss` runtime naar `assets/css_compiled/theme.css` via scssphp
|
||||||
- `css_compiled/` is **read-only** — niet handmatig aanpassen (wordt verborgen in de thema-editor)
|
- `css_compiled/` is **read-only** — niet handmatig aanpassen
|
||||||
- `assets/css/theme.css` mag **niet** bestaan; anders negeert `ThemeManager::getCssUrl()` de SCSS
|
- `assets/css/theme.css` mag **niet** bestaan; anders negeert `ThemeManager::getCssUrl()` de SCSS
|
||||||
- Forceer compilatie via de **SCSS compileren** knop in admin (of verwijder `assets/css_compiled/theme.css` en `.mtime`)
|
- Na SCSS wijzigingen: verwijder `assets/css_compiled/theme.css` en `.mtime` om te forceren
|
||||||
|
|
||||||
## Layouts
|
## Layouts
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,84 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
PHP content bestanden (`.php`) draaien binnen het CMS en hebben toegang tot een
|
De Content API is beschikbaar in PHP content bestanden (`.php`) en biedt een veilige, read-only interface tot CMS data.
|
||||||
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 }}`.
|
|
||||||
|
|
||||||
## Hoe je content doorgeeft aan de Twig template
|
## Gebruik in PHP content bestanden
|
||||||
|
|
||||||
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 */
|
||||||
?>
|
|
||||||
<h1>Hallo <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
// Alle pagina's ophalen
|
||||||
<p>Welkom op mijn pagina.</p>
|
$allPages = $api->getAllPages();
|
||||||
|
// 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);
|
||||||
```
|
```
|
||||||
|
|
||||||
Voorbeeld met return:
|
## Beschikbare methods
|
||||||
|
|
||||||
```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 content entries (mappen + bestanden) als een list van `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
- `getAllPages(): array` - Alle pagina's als `['pad' => 'titel']` pairs
|
||||||
- `getPage(string $path): ?array` — Specifieke pagina; levert `title`, `content`, `path`, `layout`, `metadata`. `$path` mag een extensie bevatten.
|
- `getPage(string $path): ?array` - Specifieke pagina met `title`, `content`, `path`, `layout`, `metadata`
|
||||||
- `pageExists(string $path): bool` — Controleer of een pagina bestaat
|
- `pageExists(string $path): bool` - Controleer of een pagina bestaat
|
||||||
- `getCurrentPageTitle(): string` — Titel van de huidige pagina
|
- `getCurrentPageTitle(): string` - Titel van huidige pagina
|
||||||
- `getCurrentPagePath(): string` — Pad van de huidige pagina
|
- `getCurrentPagePath(): string` - Pad van huidige pagina
|
||||||
- `isHomepage(): bool` — Of de huidige pagina de homepage is
|
- `isHomepage(): bool` - Of 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
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Bouw een URL voor een pagina
|
||||||
|
|
||||||
### 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
|
- `getAvailableLanguages(): array` - Beschikbare talen (bijv. `['nl', 'en']`)
|
||||||
- `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
|
||||||
|
|
||||||
### Auteur
|
## Voorbeeld: Recentste pagina's tonen
|
||||||
|
|
||||||
- `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 */
|
||||||
$entries = $api->getAllPages();
|
$pages = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach ($entries as $entry): ?>
|
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
||||||
<?php if ($entry['type'] === 'folder') continue; // sla mappen over ?>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
||||||
<?= htmlspecialchars($entry['title']) ?>
|
<?= htmlspecialchars($title) ?>
|
||||||
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config waarde gebruiken
|
## Voorbeeld: 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)): ?>
|
||||||
@@ -137,48 +89,9 @@ 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
|
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare methods:
|
||||||
methods:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$this->api->getCurrentPageTitle();
|
$this->api->getCurrentPageTitle();
|
||||||
@@ -197,10 +110,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
|
||||||
@@ -88,29 +88,6 @@ return [
|
|||||||
'col_size' => 'Größe',
|
'col_size' => 'Größe',
|
||||||
'col_modified' => 'Geändert',
|
'col_modified' => 'Geändert',
|
||||||
'col_actions' => 'Aktionen',
|
'col_actions' => 'Aktionen',
|
||||||
|
|
||||||
// Content editor (content-files) — Phase 1-5 der Content-Konsistenz TODO
|
|
||||||
'content_editor' => 'Content-Editor',
|
|
||||||
'content_files' => 'Content-Dateien',
|
|
||||||
'content_no_files' => 'Keine Dateien gefunden.',
|
|
||||||
'content_not_editable' => 'Dieser Dateityp kann im Editor nicht bearbeitet werden.',
|
|
||||||
'content_select_file' => 'Wählen Sie eine Datei aus der Seitenleiste zum Bearbeiten.',
|
|
||||||
'content_file_path' => 'Dateipfad innerhalb von content',
|
|
||||||
'content_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterordner werden automatisch erstellt. Die Erweiterung wird unten ausgewählt.',
|
|
||||||
'content_upload_help' => 'Dateien werden in content/ hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, Office-Dokumente, CSS, SCSS, JS, JSON, HTML, MD.',
|
|
||||||
'file_type' => 'Dateityp',
|
|
||||||
'list_view' => 'Listenansicht',
|
|
||||||
'tree_view' => 'Baumansicht',
|
|
||||||
'rename_folder' => 'Ordner umbenennen',
|
|
||||||
'rename_prefix' => 'Umbenennen',
|
|
||||||
'new_name' => 'Neuer Name',
|
|
||||||
'git_init' => 'Git init',
|
|
||||||
'git_init_confirm' => 'Git-Repository in content/ initialisieren?',
|
|
||||||
'git_commit' => 'Commit',
|
|
||||||
'git_commit_confirm' => 'Änderungen an git committen?',
|
|
||||||
'git_dirty' => 'nicht committete Änderungen',
|
|
||||||
'git_clean' => 'saubere Arbeitskopie',
|
|
||||||
'git_last_commit' => 'letzter Commit:',
|
|
||||||
'col_role' => 'Rolle',
|
'col_role' => 'Rolle',
|
||||||
'col_created' => 'Erstellt',
|
'col_created' => 'Erstellt',
|
||||||
'no_files_found' => 'Keine Dateien gefunden.',
|
'no_files_found' => 'Keine Dateien gefunden.',
|
||||||
@@ -128,8 +105,6 @@ 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',
|
||||||
@@ -237,32 +212,10 @@ return [
|
|||||||
'new_theme_title' => 'Neues Theme erstellen',
|
'new_theme_title' => 'Neues Theme erstellen',
|
||||||
'theme_name' => 'Theme-Name',
|
'theme_name' => 'Theme-Name',
|
||||||
'theme_name_help' => 'Nur Buchstaben, Zahlen, Unterstriche und Bindestriche.',
|
'theme_name_help' => 'Nur Buchstaben, Zahlen, Unterstriche und Bindestriche.',
|
||||||
'theme_base' => 'Basis-Theme',
|
|
||||||
'theme_base_help' => 'Wählen Sie ein bestehendes Theme als Basis zum Kopieren oder starten Sie mit einer leeren einheitlichen Struktur. css_compiled/ wird nicht kopiert.',
|
|
||||||
'theme_base_blank' => '(leere Struktur)',
|
|
||||||
'name_label' => 'Name: ',
|
'name_label' => 'Name: ',
|
||||||
'default_layout_label' => 'Standard-Layout: ',
|
'default_layout_label' => 'Standard-Layout: ',
|
||||||
'compile_scss' => 'SCSS kompilieren',
|
'compile_scss' => 'SCSS kompilieren',
|
||||||
'compile_scss_confirm' => 'SCSS kompilieren? Dies überschreibt assets/css_compiled/theme.css.',
|
|
||||||
'scss_ok' => 'aktuell',
|
|
||||||
'scss_stale' => 'veraltet',
|
|
||||||
'scss_compiled_ok' => 'SCSS ist kompiliert und aktuell',
|
|
||||||
'scss_needs_compile' => 'SCSS-Quelle neuer als kompiliertes CSS',
|
|
||||||
'no_themes' => 'Keine Themes gefunden.',
|
'no_themes' => 'Keine Themes gefunden.',
|
||||||
'theme_default_protected' => 'Default-Theme - kann nicht gelöscht werden',
|
|
||||||
'theme_layouts' => 'Layouts: ',
|
|
||||||
'theme_actions' => 'Theme-Aktionen',
|
|
||||||
'confirm_delete_theme' => 'Sind Sie sicher, dass Sie dieses Theme löschen möchten? Alle Dateien im Theme gehen verloren.',
|
|
||||||
'theme_edit' => 'Theme bearbeiten: ',
|
|
||||||
'theme_files' => 'Theme-Dateien',
|
|
||||||
'theme_no_files' => 'Keine Dateien gefunden.',
|
|
||||||
'theme_new_file' => 'Neue Datei',
|
|
||||||
'theme_new_file_title' => 'Neue Datei erstellen',
|
|
||||||
'theme_file_path' => 'Dateipfad innerhalb des Themes',
|
|
||||||
'theme_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterordner werden automatisch erstellt. Erlaubt: twig, json, scss, css, js, html, md, php.',
|
|
||||||
'theme_not_editable' => 'Dieser Dateityp kann im Editor nicht bearbeitet werden.',
|
|
||||||
'theme_select_file' => 'Wählen Sie eine Datei aus der Seitenleiste zum Bearbeiten.',
|
|
||||||
'theme_upload_help' => 'Dateien werden in assets/ dieses Themes hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, Fonts.',
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
'system_update' => 'System-Update',
|
'system_update' => 'System-Update',
|
||||||
|
|||||||
@@ -88,29 +88,6 @@ return [
|
|||||||
'col_size' => 'Size',
|
'col_size' => 'Size',
|
||||||
'col_modified' => 'Modified',
|
'col_modified' => 'Modified',
|
||||||
'col_actions' => 'Actions',
|
'col_actions' => 'Actions',
|
||||||
|
|
||||||
// Content editor (content-files) — Phase 1-5 of content-consistency TODO
|
|
||||||
'content_editor' => 'Content editor',
|
|
||||||
'content_files' => 'Content files',
|
|
||||||
'content_no_files' => 'No files found.',
|
|
||||||
'content_not_editable' => 'This file type cannot be edited in the editor.',
|
|
||||||
'content_select_file' => 'Select a file from the sidebar to edit.',
|
|
||||||
'content_file_path' => 'File path within content',
|
|
||||||
'content_file_path_help' => 'Only letters, numbers, dots, underscores, dashes and slashes. Subfolders are created automatically. The extension is chosen below.',
|
|
||||||
'content_upload_help' => 'Files are uploaded to content/. Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
|
|
||||||
'file_type' => 'File type',
|
|
||||||
'list_view' => 'List view',
|
|
||||||
'tree_view' => 'Tree view',
|
|
||||||
'rename_folder' => 'Rename folder',
|
|
||||||
'rename_prefix' => 'Rename',
|
|
||||||
'new_name' => 'New name',
|
|
||||||
'git_init' => 'Git init',
|
|
||||||
'git_init_confirm' => 'Initialize a git repository in content/?',
|
|
||||||
'git_commit' => 'Commit',
|
|
||||||
'git_commit_confirm' => 'Commit changes to git?',
|
|
||||||
'git_dirty' => 'uncommitted changes',
|
|
||||||
'git_clean' => 'clean working tree',
|
|
||||||
'git_last_commit' => 'last commit:',
|
|
||||||
'col_role' => 'Role',
|
'col_role' => 'Role',
|
||||||
'col_created' => 'Created',
|
'col_created' => 'Created',
|
||||||
'no_files_found' => 'No files found.',
|
'no_files_found' => 'No files found.',
|
||||||
@@ -235,32 +212,10 @@ return [
|
|||||||
'new_theme_title' => 'Create new theme',
|
'new_theme_title' => 'Create new theme',
|
||||||
'theme_name' => 'Theme name',
|
'theme_name' => 'Theme name',
|
||||||
'theme_name_help' => 'Only letters, numbers, underscores and dashes.',
|
'theme_name_help' => 'Only letters, numbers, underscores and dashes.',
|
||||||
'theme_base' => 'Base theme',
|
|
||||||
'theme_base_help' => 'Choose an existing theme to copy as a base, or start with an empty uniform structure. css_compiled/ is not copied.',
|
|
||||||
'theme_base_blank' => '(empty structure)',
|
|
||||||
'name_label' => 'Name: ',
|
'name_label' => 'Name: ',
|
||||||
'default_layout_label' => 'Default layout: ',
|
'default_layout_label' => 'Default layout: ',
|
||||||
'compile_scss' => 'Compile SCSS',
|
'compile_scss' => 'Compile SCSS',
|
||||||
'compile_scss_confirm' => 'Compile SCSS? This overwrites assets/css_compiled/theme.css.',
|
|
||||||
'scss_ok' => 'up-to-date',
|
|
||||||
'scss_stale' => 'stale',
|
|
||||||
'scss_compiled_ok' => 'SCSS is compiled and up-to-date',
|
|
||||||
'scss_needs_compile' => 'SCSS source newer than compiled CSS',
|
|
||||||
'no_themes' => 'No themes found.',
|
'no_themes' => 'No themes found.',
|
||||||
'theme_default_protected' => 'Default theme - cannot be deleted',
|
|
||||||
'theme_layouts' => 'Layouts: ',
|
|
||||||
'theme_actions' => 'Theme actions',
|
|
||||||
'confirm_delete_theme' => 'Are you sure you want to delete this theme? All files in the theme will be lost.',
|
|
||||||
'theme_edit' => 'Edit theme: ',
|
|
||||||
'theme_files' => 'Theme files',
|
|
||||||
'theme_no_files' => 'No files found.',
|
|
||||||
'theme_new_file' => 'New file',
|
|
||||||
'theme_new_file_title' => 'Create new file',
|
|
||||||
'theme_file_path' => 'File path within theme',
|
|
||||||
'theme_file_path_help' => 'Only letters, numbers, dots, underscores, dashes and slashes. Subfolders are created automatically. Allowed: twig, json, scss, css, js, html, md, php.',
|
|
||||||
'theme_not_editable' => 'This file type cannot be edited in the editor.',
|
|
||||||
'theme_select_file' => 'Select a file from the sidebar to edit.',
|
|
||||||
'theme_upload_help' => 'Files are uploaded to assets/ of this theme. Allowed: images, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts.',
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
'system_update' => 'System Update',
|
'system_update' => 'System Update',
|
||||||
@@ -326,7 +281,6 @@ 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',
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ return [
|
|||||||
// Content page
|
// Content page
|
||||||
'upload' => 'Upload',
|
'upload' => 'Upload',
|
||||||
'new_folder' => 'Nieuwe map',
|
'new_folder' => 'Nieuwe map',
|
||||||
'new_folder_title' => 'Nieuwe map aanmaken',
|
|
||||||
'new_file' => 'Nieuw bestand',
|
'new_file' => 'Nieuw bestand',
|
||||||
'new_file_title' => 'Nieuw bestand aanmaken',
|
|
||||||
'select_files' => 'Bestanden selecteren',
|
'select_files' => 'Bestanden selecteren',
|
||||||
'allowed_types' => 'Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV',
|
'allowed_types' => 'Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV',
|
||||||
'upload_to_folder' => 'Uploaden naar deze map',
|
'upload_to_folder' => 'Uploaden naar deze map',
|
||||||
@@ -90,29 +88,6 @@ return [
|
|||||||
'col_size' => 'Grootte',
|
'col_size' => 'Grootte',
|
||||||
'col_modified' => 'Gewijzigd',
|
'col_modified' => 'Gewijzigd',
|
||||||
'col_actions' => 'Acties',
|
'col_actions' => 'Acties',
|
||||||
|
|
||||||
// Content editor (content-files) — Fase 1-5 van content-consistentie TODO
|
|
||||||
'content_editor' => 'Content editor',
|
|
||||||
'content_files' => 'Content bestanden',
|
|
||||||
'content_no_files' => 'Geen bestanden gevonden.',
|
|
||||||
'content_not_editable' => 'Dit bestandstype kan niet in de editor bewerkt worden.',
|
|
||||||
'content_select_file' => 'Selecteer een bestand uit de zijbalk om te bewerken.',
|
|
||||||
'content_file_path' => 'Bestandspad binnen content',
|
|
||||||
'content_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. De extensie wordt hieronder gekozen.',
|
|
||||||
'content_upload_help' => 'Bestanden worden geüpload naar content/. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
|
|
||||||
'file_type' => 'Bestandstype',
|
|
||||||
'list_view' => 'Lijst weergave',
|
|
||||||
'tree_view' => 'Boom weergave',
|
|
||||||
'rename_folder' => 'Map hernoemen',
|
|
||||||
'rename_prefix' => 'Hernoem',
|
|
||||||
'new_name' => 'Nieuwe naam',
|
|
||||||
'git_init' => 'Git init',
|
|
||||||
'git_init_confirm' => 'Git repository initialiseren in content/?',
|
|
||||||
'git_commit' => 'Commit',
|
|
||||||
'git_commit_confirm' => 'Wijzigingen committen naar git?',
|
|
||||||
'git_dirty' => 'niet-committed wijzigingen',
|
|
||||||
'git_clean' => 'schone werkmap',
|
|
||||||
'git_last_commit' => 'laatste commit:',
|
|
||||||
'col_role' => 'Rol',
|
'col_role' => 'Rol',
|
||||||
'col_created' => 'Aangemaakt',
|
'col_created' => 'Aangemaakt',
|
||||||
'no_files_found' => 'Geen bestanden gevonden.',
|
'no_files_found' => 'Geen bestanden gevonden.',
|
||||||
@@ -237,32 +212,10 @@ return [
|
|||||||
'new_theme_title' => 'Nieuw thema aanmaken',
|
'new_theme_title' => 'Nieuw thema aanmaken',
|
||||||
'theme_name' => 'Thema naam',
|
'theme_name' => 'Thema naam',
|
||||||
'theme_name_help' => 'Alleen letters, cijfers, underscores en streepjes.',
|
'theme_name_help' => 'Alleen letters, cijfers, underscores en streepjes.',
|
||||||
'theme_base' => 'Basis thema',
|
|
||||||
'theme_base_help' => 'Kies een bestaand thema als basis om te kopiëren, of start met een lege uniforme structuur. css_compiled/ wordt niet gekopieerd.',
|
|
||||||
'theme_base_blank' => '(lege structuur)',
|
|
||||||
'name_label' => 'Naam: ',
|
'name_label' => 'Naam: ',
|
||||||
'default_layout_label' => 'Default layout: ',
|
'default_layout_label' => 'Default layout: ',
|
||||||
'compile_scss' => 'SCSS compileren',
|
'compile_scss' => 'SCSS compileren',
|
||||||
'compile_scss_confirm' => 'SCSS compileren? Dit overschrijft assets/css_compiled/theme.css.',
|
|
||||||
'scss_ok' => 'up-to-date',
|
|
||||||
'scss_stale' => 'verouderd',
|
|
||||||
'scss_compiled_ok' => 'SCSS is gecompileerd en up-to-date',
|
|
||||||
'scss_needs_compile' => 'SCSS source nieuwer dan gecompileerde CSS',
|
|
||||||
'no_themes' => 'Geen thema\'s gevonden.',
|
'no_themes' => 'Geen thema\'s gevonden.',
|
||||||
'theme_default_protected' => 'Default thema - kan niet verwijderd worden',
|
|
||||||
'theme_layouts' => 'Layouts: ',
|
|
||||||
'theme_actions' => 'Thema acties',
|
|
||||||
'confirm_delete_theme' => 'Weet je zeker dat je dit thema wilt verwijderen? Alle bestanden in het thema gaan verloren.',
|
|
||||||
'theme_edit' => 'Thema bewerken: ',
|
|
||||||
'theme_files' => 'Thema bestanden',
|
|
||||||
'theme_no_files' => 'Geen bestanden gevonden.',
|
|
||||||
'theme_new_file' => 'Nieuw bestand',
|
|
||||||
'theme_new_file_title' => 'Nieuw bestand aanmaken',
|
|
||||||
'theme_file_path' => 'Bestandspad binnen thema',
|
|
||||||
'theme_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. Toegestaan: twig, json, scss, css, js, html, md, php.',
|
|
||||||
'theme_not_editable' => 'Dit bestandstype kan niet in de editor bewerkt worden.',
|
|
||||||
'theme_select_file' => 'Selecteer een bestand uit de zijbalk om te bewerken.',
|
|
||||||
'theme_upload_help' => 'Bestanden worden geüpload naar assets/ van dit thema. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts.',
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
'system_update' => 'Systeem Update',
|
'system_update' => 'Systeem Update',
|
||||||
@@ -328,7 +281,6 @@ 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',
|
||||||
|
|||||||
+102
-1857
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'version' => '2.6.3',
|
'version' => '2.6.1d',
|
||||||
'release_date' => '2026-08-20',
|
'release_date' => '2026-08-18',
|
||||||
'codename' => 'Lyra',
|
'codename' => 'Lyra',
|
||||||
'status' => 'stable',
|
'status' => 'stable',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user