Compare commits
10
Commits
v2.6.1
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e345c96d7 | ||
|
|
34bb8c6f21 | ||
|
|
d9ea2eee47 | ||
|
|
74612aefbb | ||
|
|
6485f693dc | ||
|
|
97c4d52c78 | ||
|
|
e926a3a40d | ||
|
|
7249aca885 | ||
|
|
5edc929c13 | ||
|
|
10c72de859 |
@@ -14,6 +14,7 @@ Thumbs.db
|
|||||||
admin/storage/cache/
|
admin/storage/cache/
|
||||||
admin/storage/geoip/
|
admin/storage/geoip/
|
||||||
admin/storage/stats.json
|
admin/storage/stats.json
|
||||||
|
admin/storage/logs/login_attempts.json
|
||||||
var/
|
var/
|
||||||
|
|
||||||
# Runtime-compiled theme assets (generated by scssphp, read-only)
|
# Runtime-compiled theme assets (generated by scssphp, read-only)
|
||||||
|
|||||||
+2
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
A lightweight, file-based content management system built with PHP (≥8.0).
|
A lightweight, file-based content management system built with PHP (≥8.0).
|
||||||
|
|
||||||
**Version:** 2.6.1 | **License:** AGPL v3 / Commercial
|
**Version:** 2.6.5 | **License:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -55,10 +55,9 @@ composer install
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.json.example config.json
|
cp config.json.example config.json
|
||||||
cp admin/config/admin.json.example admin/config/admin.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `config.json` with your site title, language and plugins. Change the admin password in `admin/config/admin.json` (default `admin`/`admin`).
|
Edit `config.json` with your site title, language and plugins. On the first visit to `/admin`, `admin/config/admin.json` is created automatically with a random password that is shown on the login screen. Save this password safely and change it immediately after login.
|
||||||
|
|
||||||
### Step 3a — Apache 2.4+
|
### Step 3a — Apache 2.4+
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
||||||
|
|
||||||
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
|
**Versie:** 2.6.5 | **Licentie:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -55,10 +55,9 @@ composer install
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.json.example config.json
|
cp config.json.example config.json
|
||||||
cp admin/config/admin.json.example admin/config/admin.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pas `config.json` aan met je site titel, taal en plugins. Wijzig het admin wachtwoord in `admin/config/admin.json` (standaard `admin`/`admin`).
|
Pas `config.json` aan met je site titel, taal en plugins. Bij de eerste keer dat je `/admin` opent wordt `admin/config/admin.json` automatisch aangemaakt met een willekeurig wachtwoord dat op het inlogscherm wordt getoond. Sla dit wachtwoord veilig op en wijzig het direct na login.
|
||||||
|
|
||||||
### Stap 3a — Apache 2.4+
|
### Stap 3a — Apache 2.4+
|
||||||
|
|
||||||
@@ -210,12 +209,15 @@ codepress/
|
|||||||
│ │ ├── partials/ # Header, navigation, footer
|
│ │ ├── partials/ # Header, navigation, footer
|
||||||
│ │ └── assets/ # SCSS, CSS, JS, img
|
│ │ └── assets/ # SCSS, CSS, JS, img
|
||||||
├── plugins/ # Plugins
|
├── plugins/ # Plugins
|
||||||
│ ├── HTMLBlock/ # Voorbeeld sidebar plugin
|
│ ├── Dashboard/ # Systeem plugin (admin taal)
|
||||||
│ └── Navigation/ # Essentiële navigatie plugin (beschermd)
|
│ ├── HTMLBlock/ # Content plugin (content taal)
|
||||||
│ ├── Navigation.php # Plugin code
|
│ ├── Navigation/ # Essentiële navigatie plugin (beschermd)
|
||||||
│ ├── plugin.json # Plugin metadata
|
│ └── Statistics/ # Systeem plugin (admin taal)
|
||||||
│ ├── assets/scss/ # Plugin SCSS bron
|
│ ├── Statistics.php # Plugin code
|
||||||
│ └── assets/css/ # Plugin CSS
|
│ ├── plugin.json # Plugin metadata + instellingen
|
||||||
|
│ ├── README.md # Plugin documentatie
|
||||||
|
│ ├── assets/ # Plugin CSS/JS/SCSS
|
||||||
|
│ └── language/ # Plugin vertalingen (nl/, en/)
|
||||||
├── content/ # Website content (.md, .php, .html)
|
├── content/ # Website content (.md, .php, .html)
|
||||||
├── public/ # Web root
|
├── public/ # Web root
|
||||||
│ ├── index.php # Website entry point
|
│ ├── index.php # Website entry point
|
||||||
@@ -281,14 +283,33 @@ codepress/
|
|||||||
|
|
||||||
### Plugin structuur
|
### Plugin structuur
|
||||||
|
|
||||||
|
Elke plugin heeft een uniforme structuur:
|
||||||
|
|
||||||
```
|
```
|
||||||
plugins/MijnPlugin/
|
plugins/MijnPlugin/
|
||||||
├── MijnPlugin.php # Plugin code (naam = pluginnaam)
|
├── MijnPlugin.php # Plugin code (naam = pluginnaam)
|
||||||
├── plugin.json # Plugin metadata
|
├── plugin.json # Plugin metadata + instellingen
|
||||||
|
├── README.md # Plugin documentatie
|
||||||
├── assets/scss/ # Plugin SCSS bron
|
├── assets/scss/ # Plugin SCSS bron
|
||||||
└── assets/css/ # Plugin CSS (na compilatie)
|
├── assets/css/ # Plugin CSS (na compilatie)
|
||||||
|
└── language/ # Plugin vertalingen (i18n)
|
||||||
|
├── nl/admin.php # NL admin labels (systeem plugins)
|
||||||
|
└── en/admin.php # EN admin labels
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Systeem plugins** volgen de admin-taal (`language/<lang>/admin.php`)
|
||||||
|
- **Content plugins** volgen de content-taal (`language/<lang>/site.php`)
|
||||||
|
- Fallback chain: geselecteerde taal → plugin `default_language` → CMS site default
|
||||||
|
|
||||||
|
Zie `guide/nl/codepress-developer/plugin-development.md` voor uitgebreide documentatie.
|
||||||
|
|
||||||
|
### Plugin editor
|
||||||
|
|
||||||
|
De admin plugin-editor (`/admin/plugins-edit`) biedt een volledige bestandsbeheer-omgeving:
|
||||||
|
- Geneste bestandsbrowser zijbalk (alle bestanden in de plugin-map)
|
||||||
|
- Nieuw bestand aanmaken, uploaden naar assets/, verwijderen en verplaatsen
|
||||||
|
- CodeMirror editor voor .php, .json, .md, .html, .css, .scss, .js bestanden
|
||||||
|
|
||||||
### Essentiële plugins
|
### Essentiële plugins
|
||||||
|
|
||||||
De **Navigation** plugin is een essentiële plugin en kan niet worden gedeactiveerd, bewerkt of verwijderd. Deze plugin genereert automatisch de zijbalknavigatie voor handleidingen en content.
|
De **Navigation** plugin is een essentiële plugin en kan niet worden gedeactiveerd, bewerkt of verwijderd. Deze plugin genereert automatisch de zijbalknavigatie voor handleidingen en content.
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Voor elke versie verhoging. Deze nooit weghalen.
|
|
||||||
- [ ] Pentest controles uitgevoerd
|
|
||||||
- [ ] WCAG 2.1 AA accessibility tests
|
|
||||||
- [ ] Volledige git commit maken.
|
|
||||||
- [ ] Verslag maken voor opdrcht gever
|
|
||||||
- [ ] Na convormatie versie verhogen
|
|
||||||
- [ ] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde.
|
|
||||||
|
|
||||||
## Te doen ⏳
|
## Voltooid ✅
|
||||||
|
|
||||||
|
- [x] diep link werken niet goed. Pagina titels worden wel herkend maar de gegenereerde link werken niet. — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404). Skip-regex uitgebreid met dynamische taal-prefixen via getAvailableLanguages().
|
||||||
|
- [x] De tree van de admin/content admin/theme en admin/plugin
|
||||||
|
- [x] Mappen en bestanden moeten kunnen worden versleept kunnen worden om te verplaatsen. — Drag-and-drop via tree-interactions.js + uniform AJAX move endpoint (/admin/tree-move) voor alle drie de scopes.
|
||||||
|
- [x] Ik moet in de tree ook een bestand/map kunnen selecteren
|
||||||
|
- [x] om te kunnen editen — klik op bestand opent editor, klik op map opent map-detail paneel
|
||||||
|
- [x] verwijderen — prullenbak-knop per bestand in tree + verwijder-knop per map in map-detail paneel
|
||||||
|
- [x] op deze plaats een nieuwe map/bestand te kunnen maken — per-map actie-knoppen in tree (nieuw bestand, nieuwe map) + knoppen in map-detail paneel
|
||||||
|
- [x] verplaatsen — drag-and-drop + potlood-knop per bestand (verplaats-formulier)
|
||||||
|
- [x] Alle tree's moeten op de zelfde manier kunnen werken — _file-tree.twig is uniform: alle drie de trees hebben treeDirActions, treeDirSelectRoute, drag-drop, per-node actie-knoppen, map-detail paneel. Nieuwe routes: theme-dir-create-in/rename-in/delete-in, plugins-dir-create-in/rename-in/delete-in, tree-move.
|
||||||
|
|
||||||
|
## Nice to have
|
||||||
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
|
||||||
- [ ] Fase 1: Config-resolutie
|
- [ ] Fase 1: Config-resolutie
|
||||||
- [ ] For apache instants domein settings /public/noorlander.info/ or /public/mycode.name/
|
- [ ] For apache instants domein settings /public/noorlander.info/ or /public/mycode.name/
|
||||||
@@ -33,6 +37,19 @@
|
|||||||
- [ ] AGENTS.md + config.json.example bijwerken
|
- [ ] AGENTS.md + config.json.example bijwerken
|
||||||
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
||||||
|
|
||||||
|
## v2.6.5 (2026-08-27) ✅
|
||||||
|
- [x] Bug: dashboard toonde 0 content (AdminPluginAPI::getContentDir() gaf relatief pad "content" terug zonder normalisatie tegen project-root; Apache CWD=public/ → 0 bestanden)
|
||||||
|
- [x] Dynamische pad-resolutie: PluginAPIInterface uitgebreid met getProjectRoot()/getContentDir()/getPluginsDir()/getVersionInfo(); CMSAPI en AdminPluginAPI implementeren deze nu universeel; Navigation plugin en Logs plugin halen paden via de API i.p.v. hardcoded dirname(__DIR__)
|
||||||
|
- [x] public/index.php media-/assets-serving gebruikt $config['content_dir'] i.p.v. hardcoded /content
|
||||||
|
- [x] WordPress-stijl docblocks toegevoegd voor alle classes, methods, properties en functies (~450 docblocks, @since 2.6.5)
|
||||||
|
- [x] Security: hardcoded plaintext-wachtwoord 'admin' verwijderd uit AdminAuth.php; bij eerste installatie wordt een cryptografisch veilig wachtwoord gegenereerd (random_bytes, 16 tekens) en eenmalig op het inlogscherm getoond
|
||||||
|
- [x] Security: git-geschiedenis schoongemaakt — admin/config/admin.json, admin/config/admin.json.example en admin-console/config/admin.json verwijderd uit alle commits (bevatten kraakbare bcrypt-hashes naar 'admin'); filter-branch over alle branches + 10 tags, reflog expired, gc --prune --aggressive
|
||||||
|
- [x] README.md, README.en.md, AGENTS.md bijgewerkt (admin.json.example referenties verwijderd, inlog-instructies herschreven)
|
||||||
|
|
||||||
|
## v2.6.4 (2026-08-26) ✅
|
||||||
|
- [x] Bug: diep link werken niet goed — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404); skip-regex dynamisch uitgebreid met getAvailableLanguages()
|
||||||
|
- [x] Bug: uniforme tree met drag-and-drop voor admin/content, admin/theme, admin/plugin — tree-interactions.js (drag-drop + per-node actie-knoppen), uniform AJAX move endpoint (/admin/tree-move), map-detail paneel voor alle drie de scopes, theme/plugin dir create/rename/delete routes, handleidingen bijgewerkt
|
||||||
|
|
||||||
## Voltooid ✅
|
## Voltooid ✅
|
||||||
|
|
||||||
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
||||||
@@ -49,6 +66,67 @@
|
|||||||
- [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) ✅
|
||||||
|
- [x] Pentest controles uitgevoerd (30/30)
|
||||||
|
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||||
|
- [x] Plugin internationalisatie (i18n): plugins hebben eigen language/ mappen, systeem plugins volgen admin taal, content plugins volgen content taal, fallback chain
|
||||||
|
- [x] Plugin uniformiteit: alle 6 plugins hebben uniforme structuur (README.md, assets/.gitkeep, language/nl|en/)
|
||||||
|
- [x] Plugin editor vernieuwd: bestandsbrowser zijbalk (geneste boom), nieuw bestand aanmaken, uploaden, verwijderen, verplaatsen
|
||||||
|
- [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] 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] Versie verhoogd naar 2.6.1d
|
||||||
|
|
||||||
|
## v2.6.1c (2026-08-17) ✅
|
||||||
|
- [x] Pentest controles uitgevoerd (30/30)
|
||||||
|
- [x] WCAG 2.1 AA accessibility tests (25/25)
|
||||||
|
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt
|
||||||
|
- [x] Admin gebruikersbeheer opnieuw ontworpen (lijst + zoeken/filter + profiel + wachtwoord)
|
||||||
|
- [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] Versie verhoogd naar 2.6.1c
|
||||||
|
|
||||||
## v2.6.1 (2026-08-17) ✅
|
## v2.6.1 (2026-08-17) ✅
|
||||||
- [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)
|
||||||
|
|||||||
+486
-14
@@ -1,28 +1,73 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminAuth - File-based authentication for CodePress Admin
|
* File-based authenticatie en autorisatie voor het CodePress admin paneel.
|
||||||
|
*
|
||||||
|
* Beheert gebruikerssessies, rollen met permissies, CSRF-tokens, brute-force
|
||||||
|
* lockout en gebruikersbeheer. Alle data wordt opgeslagen in JSON-bestanden
|
||||||
|
* (geen database).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class AdminAuth
|
class AdminAuth
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Applicatie-configuratie uit config.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Applicatie-configuratie.
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-configuratie geladen uit admin.json (gebruikers en security-instellingen).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Admin-configuratie met keys 'users' en 'security'.
|
||||||
|
*/
|
||||||
private array $adminConfig;
|
private array $adminConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad naar het JSON-bestand met mislukte inlogpogingen voor brute-force lockout.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Absoluut pad naar login_attempts.json.
|
||||||
|
*/
|
||||||
private string $lockFile;
|
private string $lockFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role definitions with permissions.
|
* Tijdelijk gegenereerd wachtwoord bij eerste installatie.
|
||||||
* Each role maps to a list of allowed route prefixes.
|
*
|
||||||
* 'admin' has wildcard '*' access.
|
* Wordt gevuld wanneer loadAdminConfig() een nieuwe admin.json aanmaakt
|
||||||
|
* met een cryptografisch veilig willekeurig wachtwoord. De login-pagina
|
||||||
|
* toont dit wachtwoord eenmalig via getGeneratedPassword().
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Leeg tenzij net aangemaakt bij eerste installatie.
|
||||||
|
*/
|
||||||
|
private string $generatedPassword = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissies per rol.
|
||||||
|
*
|
||||||
|
* Elke rol mapt naar een lijst van toegestane route-prefixen. De rol
|
||||||
|
* 'admin' heeft wildcard '*' toegang tot alle routes.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string, string[]> Map van rol-sleutel naar lijst met toegestane routes.
|
||||||
*/
|
*/
|
||||||
public const ROLE_PERMISSIONS = [
|
public const ROLE_PERMISSIONS = [
|
||||||
'admin' => ['*'],
|
'admin' => ['*'],
|
||||||
'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'],
|
'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'],
|
||||||
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
|
||||||
'site-admin' => ['dashboard', 'theme', 'theme-new', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', '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'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable role labels.
|
* Leesbare rol-labels voor weergave in de admin-interface.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string, string> Map van rol-sleutel naar label.
|
||||||
*/
|
*/
|
||||||
public const ROLE_LABELS = [
|
public const ROLE_LABELS = [
|
||||||
'admin' => 'Admin',
|
'admin' => 'Admin',
|
||||||
@@ -31,6 +76,16 @@ class AdminAuth
|
|||||||
'site-admin' => 'Site Admin',
|
'site-admin' => 'Site Admin',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor: initialiseer authenticatie met de applicatie-configuratie.
|
||||||
|
*
|
||||||
|
* Laadt de admin-configuratie, bepaalt het pad naar het lockout-bestand en
|
||||||
|
* start de sessie met de juiste security-instellingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $appConfig Applicatie-configuratie; vereist keys 'log_file' en 'admin_config'.
|
||||||
|
*/
|
||||||
public function __construct(array $appConfig)
|
public function __construct(array $appConfig)
|
||||||
{
|
{
|
||||||
$this->config = $appConfig;
|
$this->config = $appConfig;
|
||||||
@@ -39,6 +94,17 @@ class AdminAuth
|
|||||||
$this->startSession();
|
$this->startSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laadt de admin-configuratie uit admin.json.
|
||||||
|
*
|
||||||
|
* Als het bestand nog niet bestaat wordt het aangemaakt vanuit
|
||||||
|
* admin.json.example, of indien die ook ontbreekt met een standaard admin-
|
||||||
|
* gebruiker. Geeft een lege structuur terug als de JSON ongeldig is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Admin-configuratie met keys 'users' en 'security'.
|
||||||
|
*/
|
||||||
private function loadAdminConfig(): array
|
private function loadAdminConfig(): array
|
||||||
{
|
{
|
||||||
$path = $this->config['admin_config'];
|
$path = $this->config['admin_config'];
|
||||||
@@ -48,11 +114,19 @@ class AdminAuth
|
|||||||
if (file_exists($examplePath)) {
|
if (file_exists($examplePath)) {
|
||||||
@copy($examplePath, $path);
|
@copy($examplePath, $path);
|
||||||
} else {
|
} else {
|
||||||
|
// Genereer een cryptografisch veilig wachtwoord bij eerste
|
||||||
|
// installatie. Het wachtwoord wordt nooit in de broncode
|
||||||
|
// opgeslagen; de hash wordt weggeschreven naar admin.json
|
||||||
|
// en het plaintext wachtwoord éénmalig getoond op het
|
||||||
|
// inlogscherm via getGeneratedPassword().
|
||||||
|
$plainPassword = $this->generateRandomPassword();
|
||||||
|
$this->generatedPassword = $plainPassword;
|
||||||
|
|
||||||
$defaultAdminConfig = [
|
$defaultAdminConfig = [
|
||||||
'users' => [
|
'users' => [
|
||||||
[
|
[
|
||||||
'username' => 'admin',
|
'username' => 'admin',
|
||||||
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
|
'password_hash' => password_hash($plainPassword, PASSWORD_BCRYPT),
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
'created' => date('Y-m-d'),
|
'created' => date('Y-m-d'),
|
||||||
]
|
]
|
||||||
@@ -74,6 +148,55 @@ class AdminAuth
|
|||||||
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genereer een cryptografisch veilig willekeurig wachtwoord.
|
||||||
|
*
|
||||||
|
* Gebruik random_bytes() (CSPRNG) met een mix van alfanumerieke tekens
|
||||||
|
* en een vaste lengte, zodat het wachtwoord voldoende entropie heeft
|
||||||
|
* en veilig te typen is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Willekeurig wachtwoord van 16 alfanumerieke tekens.
|
||||||
|
*/
|
||||||
|
private function generateRandomPassword(int $length = 16): string
|
||||||
|
{
|
||||||
|
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
$max = strlen($alphabet) - 1;
|
||||||
|
$password = '';
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$password .= $alphabet[random_int(0, $max)];
|
||||||
|
}
|
||||||
|
return $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geef het bij eerste installatie gegenereerde wachtwoord terug.
|
||||||
|
*
|
||||||
|
* De login-pagina gebruikt dit om het wachtwoord eenmalig te tonen
|
||||||
|
* zodra admin.json is aangemaakt. Na de eerste succesvolle login
|
||||||
|
* wordt het veld geleegd. Geeft een lege string terug wanneer er
|
||||||
|
* geen nieuw wachtwoord is gegenereerd in deze request.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Plaintext wachtwoord of lege string.
|
||||||
|
*/
|
||||||
|
public function getGeneratedPassword(): string
|
||||||
|
{
|
||||||
|
return $this->generatedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slaat de huidige admin-configuratie op naar admin.json.
|
||||||
|
*
|
||||||
|
* Schrijft de interne admin-configuratie weg als pretty-printed JSON met
|
||||||
|
* behoud van unicode-tekens.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function saveAdminConfig(): void
|
public function saveAdminConfig(): void
|
||||||
{
|
{
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
@@ -82,6 +205,17 @@ class AdminAuth
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start de sessie met veilige cookie-parameters en handhaaft de session timeout.
|
||||||
|
*
|
||||||
|
* Stelt cookie-flags in (HttpOnly, SameSite=Strict, Secure bij HTTPS), start
|
||||||
|
* de sessie en beëindigt deze bij inactiviteit langer dan de geconfigureerde
|
||||||
|
* timeout. Vernieuwt de activiteit-timestamp bij geauthenticeerde gebruikers.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function startSession(): void
|
private function startSession(): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -113,6 +247,20 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifieert inloggegevens en start een admin-sessie bij succes.
|
||||||
|
*
|
||||||
|
* Controleert eerst of het account niet is vergrendeld door brute-force
|
||||||
|
* protection. Bij een fout wachtwoord wordt een mislukte poging geregistreerd
|
||||||
|
* en gelogd. Bij succes worden mislukte pogingen gewist en de sessie
|
||||||
|
* gevuld met gebruiker, rol en CSRF-token.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $password Wachtwoord (plaintext).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function login(string $username, string $password): array
|
public function login(string $username, string $password): array
|
||||||
{
|
{
|
||||||
// Check brute-force lockout
|
// Check brute-force lockout
|
||||||
@@ -145,6 +293,16 @@ class AdminAuth
|
|||||||
return ['success' => true, 'message' => 'Ingelogd.'];
|
return ['success' => true, 'message' => 'Ingelogd.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logt de huidige gebruiker uit en vernietigt de sessie.
|
||||||
|
*
|
||||||
|
* Maakt de sessie-array leeg, verwijdert het sessie-cookie en vernietigt de
|
||||||
|
* sessie. De uitlogactie wordt gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
$user = $_SESSION['admin_user'] ?? 'unknown';
|
$user = $_SESSION['admin_user'] ?? 'unknown';
|
||||||
@@ -160,11 +318,28 @@ class AdminAuth
|
|||||||
$this->log('info', "Uitgelogd: {$user}");
|
$this->log('info', "Uitgelogd: {$user}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleert of de huidige bezoeker is ingelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien een admin-gebruiker in de sessie staat.
|
||||||
|
*/
|
||||||
public function isAuthenticated(): bool
|
public function isAuthenticated(): bool
|
||||||
{
|
{
|
||||||
return isset($_SESSION['admin_user']);
|
return isset($_SESSION['admin_user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de gegevens van de ingelogde gebruiker, verrijkt met profielvelden.
|
||||||
|
*
|
||||||
|
* Levert username en rol uit de sessie en vult deze aan met email,
|
||||||
|
* author_name en author_email uit admin.json indien aanwezig.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array|null Gebruikersdata als array, of null indien niet ingelogd.
|
||||||
|
*/
|
||||||
public function getCurrentUser(): ?array
|
public function getCurrentUser(): ?array
|
||||||
{
|
{
|
||||||
if (!$this->isAuthenticated()) {
|
if (!$this->isAuthenticated()) {
|
||||||
@@ -186,15 +361,110 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the role of the current user.
|
* Geeft de huidige rol van de ingelogde gebruiker.
|
||||||
|
*
|
||||||
|
* Retourneert de override-rol indien ingesteld (voor test-doeleinden), anders
|
||||||
|
* de werkelijke rol.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Rol-sleutel.
|
||||||
*/
|
*/
|
||||||
public function getCurrentRole(): string
|
public function getCurrentRole(): string
|
||||||
|
{
|
||||||
|
if (isset($_SESSION['admin_role_override'])) {
|
||||||
|
return $_SESSION['admin_role_override'];
|
||||||
|
}
|
||||||
|
return $_SESSION['admin_role'] ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de werkelijke rol van de ingelogde gebruiker, eventuele override negerend.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Rol-sleutel.
|
||||||
|
*/
|
||||||
|
public function getRealRole(): string
|
||||||
{
|
{
|
||||||
return $_SESSION['admin_role'] ?? 'admin';
|
return $_SESSION['admin_role'] ?? 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user has permission to access a route.
|
* Controleert of de ingelogde gebruiker een actieve rol-override heeft.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien een override-rol is ingesteld.
|
||||||
|
*/
|
||||||
|
public function hasRoleOverride(): bool
|
||||||
|
{
|
||||||
|
return isset($_SESSION['admin_role_override']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wisselt tijdelijk naar een andere rol voor test-doeleinden.
|
||||||
|
*
|
||||||
|
* Alleen werkelijke admins kunnen wisselen; admins kunnen niet wisselen naar
|
||||||
|
* 'admin' (zinloos) en enkel naar lagere rollen. Slaat de override op in de
|
||||||
|
* sessie. De actie wordt gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $role Doel-rol-sleutel.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
|
public function switchRole(string $role): array
|
||||||
|
{
|
||||||
|
if (!$this->isAuthenticated()) {
|
||||||
|
return ['success' => false, 'message' => 'Niet ingelogd.'];
|
||||||
|
}
|
||||||
|
// Only real admins can switch roles
|
||||||
|
$realRole = $this->getRealRole();
|
||||||
|
if ($realRole !== 'admin') {
|
||||||
|
return ['success' => false, 'message' => 'Alleen admins kunnen van rol wisselen.'];
|
||||||
|
}
|
||||||
|
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||||
|
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||||
|
}
|
||||||
|
// Admins cannot switch to 'admin' (no point) — only to lower roles for testing
|
||||||
|
if ($role === 'admin') {
|
||||||
|
return ['success' => false, 'message' => 'Je bent al admin.'];
|
||||||
|
}
|
||||||
|
$_SESSION['admin_role_override'] = $role;
|
||||||
|
$this->log('info', "Rol-switch: {$_SESSION['admin_user']} -> {$role} (test)");
|
||||||
|
return ['success' => true, 'message' => 'Rol gewijzigd naar ' . self::getRoleLabel($role) . '.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset de rol-override terug naar de werkelijke admin-rol.
|
||||||
|
*
|
||||||
|
* Verwijdert de override uit de sessie indien aanwezig en logt de actie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
|
public function resetRole(): array
|
||||||
|
{
|
||||||
|
if (isset($_SESSION['admin_role_override'])) {
|
||||||
|
unset($_SESSION['admin_role_override']);
|
||||||
|
$this->log('info', "Rol-switch gereset: {$_SESSION['admin_user']}");
|
||||||
|
return ['success' => true, 'message' => 'Rol teruggezet naar admin.'];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'message' => 'Geen rol-override actief.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleert of de huidige gebruiker een bepaalde route mag openen.
|
||||||
|
*
|
||||||
|
* Gebruikt de permissies van de huidige rol; de wildcard '*' geeft toegang
|
||||||
|
* tot alles. Onbekende rollen krijgen enkel 'dashboard' en 'logout'.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $route Route-sleutel die gecontroleerd moet worden.
|
||||||
|
* @return bool True indien de gebruiker de route mag benaderen.
|
||||||
*/
|
*/
|
||||||
public function hasPermission(string $route): bool
|
public function hasPermission(string $route): bool
|
||||||
{
|
{
|
||||||
@@ -209,7 +479,11 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available roles.
|
* Geeft alle beschikbare rollen met hun leesbare labels.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, string> Map van rol-sleutel naar label.
|
||||||
*/
|
*/
|
||||||
public static function getRoles(): array
|
public static function getRoles(): array
|
||||||
{
|
{
|
||||||
@@ -217,13 +491,28 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get role label.
|
* Geeft het leesbare label voor een rol.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $role Rol-sleutel.
|
||||||
|
* @return string Leesbaar label, of de rol-sleutel zelf indien onbekend.
|
||||||
*/
|
*/
|
||||||
public static function getRoleLabel(string $role): string
|
public static function getRoleLabel(string $role): string
|
||||||
{
|
{
|
||||||
return self::ROLE_LABELS[$role] ?? $role;
|
return self::ROLE_LABELS[$role] ?? $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft het huidige CSRF-token, of genereert een nieuw token indien afwezig.
|
||||||
|
*
|
||||||
|
* Slaat een cryptografisch willekeurig token op in de sessie en retourneert
|
||||||
|
* dit voor gebruik in admin-formulieren.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string 64-tekens hexadecimaal CSRF-token.
|
||||||
|
*/
|
||||||
public function getCsrfToken(): string
|
public function getCsrfToken(): string
|
||||||
{
|
{
|
||||||
if (!isset($_SESSION['admin_csrf_token'])) {
|
if (!isset($_SESSION['admin_csrf_token'])) {
|
||||||
@@ -232,11 +521,31 @@ class AdminAuth
|
|||||||
return $_SESSION['admin_csrf_token'];
|
return $_SESSION['admin_csrf_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifieert een meegegeven CSRF-token tegen het token in de sessie.
|
||||||
|
*
|
||||||
|
* Gebruikt hash_equals om timing-attacks te voorkomen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $token Token uit het formulier of verzoek.
|
||||||
|
* @return bool True indien het token overeenkomt met het sessie-token.
|
||||||
|
*/
|
||||||
public function verifyCsrf(string $token): bool
|
public function verifyCsrf(string $token): bool
|
||||||
{
|
{
|
||||||
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeneert het CSRF-token in de sessie.
|
||||||
|
*
|
||||||
|
* Maakt een nieuw cryptografisch willekeurig token aan, nuttig na
|
||||||
|
* privilege-wijzigingen of na het verwerken van een formulier.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function regenerateCsrfToken(): void
|
public function regenerateCsrfToken(): void
|
||||||
{
|
{
|
||||||
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||||
@@ -244,6 +553,16 @@ class AdminAuth
|
|||||||
|
|
||||||
// --- User Management ---
|
// --- User Management ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft alle gebruikers met hun profiel- en rolinformatie.
|
||||||
|
*
|
||||||
|
* Levert een array geïndexeerd op gebruikersnaam, aangevuld met het
|
||||||
|
* leesbare rol-label en eventuele profielvelden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, array> Map van gebruikersnaam naar gebruikersdata.
|
||||||
|
*/
|
||||||
public function getUsers(): array
|
public function getUsers(): array
|
||||||
{
|
{
|
||||||
$users = [];
|
$users = [];
|
||||||
@@ -262,6 +581,23 @@ class AdminAuth
|
|||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voegt een nieuwe gebruiker toe aan de admin-configuratie.
|
||||||
|
*
|
||||||
|
* Controleert op dubbele gebruikersnamen, minimum wachtwoordlengte (8) en
|
||||||
|
* geldigheid van de rol. Het wachtwoord wordt opgeslagen als bcrypt-hash.
|
||||||
|
* Wijzigingen worden direct weggeschreven en gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $password Wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @param string $role Rol-sleutel. Default 'admin'.
|
||||||
|
* @param string $email E-mailadres van de gebruiker. Default ''.
|
||||||
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||||
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
{
|
{
|
||||||
if ($this->findUser($username)) {
|
if ($this->findUser($username)) {
|
||||||
@@ -289,7 +625,18 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the profile (email, author_name, author_email) of a user.
|
* Werkt de profielvelden (email, author_name, author_email) van een gebruiker bij.
|
||||||
|
*
|
||||||
|
* Zoekt de gebruiker op naam en overschrijft de profielvelden, slaat de
|
||||||
|
* configuratie op en logt de wijziging.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $email E-mailadres. Default ''.
|
||||||
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||||
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
{
|
{
|
||||||
@@ -307,10 +654,23 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the role of an existing user.
|
* Wijzigt de rol van een bestaande gebruiker.
|
||||||
|
*
|
||||||
|
* Een gebruiker kan nooit zijn eigen rol wijzigen (security guard). De rol
|
||||||
|
* moet een bekende sleutel uit ROLE_PERMISSIONS zijn. Wijzigingen worden
|
||||||
|
* opgeslagen en gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $role Nieuwe rol-sleutel.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function changeRole(string $username, string $role): array
|
public function changeRole(string $username, string $role): array
|
||||||
{
|
{
|
||||||
|
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||||
|
return ['success' => false, 'message' => 'Je kunt je eigen rol niet wijzigen.'];
|
||||||
|
}
|
||||||
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
||||||
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
||||||
}
|
}
|
||||||
@@ -325,6 +685,17 @@ class AdminAuth
|
|||||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwijdert een gebruiker uit de admin-configuratie.
|
||||||
|
*
|
||||||
|
* Een gebruiker kan zichzelf niet verwijderen. De gebruikerslijst wordt
|
||||||
|
* gefilterd, opgeslagen en de actie gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function deleteUser(string $username): array
|
public function deleteUser(string $username): array
|
||||||
{
|
{
|
||||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||||
@@ -340,6 +711,18 @@ class AdminAuth
|
|||||||
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zet een nieuw wachtwoord voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Controleert de minimum wachtwoordlengte (8), hasht het wachtwoord met de
|
||||||
|
* standaard PHP-hash en slaat de wijziging op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function changePassword(string $username, string $newPassword): array
|
public function changePassword(string $username, string $newPassword): array
|
||||||
{
|
{
|
||||||
if (strlen($newPassword) < 8) {
|
if (strlen($newPassword) < 8) {
|
||||||
@@ -356,6 +739,19 @@ class AdminAuth
|
|||||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wijzigt het eigen wachtwoord na verificatie van het huidige wachtwoord.
|
||||||
|
*
|
||||||
|
* Verifieert eerst het huidige wachtwoord tegen de opgeslagen hash, controleert
|
||||||
|
* de lengte van het nieuwe wachtwoord (minimaal 8) en slaat de nieuwe hash op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $currentPassword Huidig wachtwoord (plaintext).
|
||||||
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
|
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
|
||||||
{
|
{
|
||||||
$user = $this->findUser($username);
|
$user = $this->findUser($username);
|
||||||
@@ -381,6 +777,14 @@ class AdminAuth
|
|||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoekt een gebruiker op gebruikersnaam in de admin-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array|null Gebruikersdata als array, of null indien niet gevonden.
|
||||||
|
*/
|
||||||
private function findUser(string $username): ?array
|
private function findUser(string $username): ?array
|
||||||
{
|
{
|
||||||
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
||||||
@@ -391,6 +795,18 @@ class AdminAuth
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleert of een gebruiker momenteel is vergrendeld door brute-force protection.
|
||||||
|
*
|
||||||
|
* Vergelijkt het aantal mislukte pogingen en de verstreken tijd sinds de
|
||||||
|
* laatste poging met de geconfigureerde limieten. Verloopt de lockout dan
|
||||||
|
* worden de pogingen gewist.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array Resultaat met keys 'locked' (bool) en optioneel 'remaining' (int, seconden).
|
||||||
|
*/
|
||||||
private function checkLockout(string $username): array
|
private function checkLockout(string $username): array
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -414,6 +830,17 @@ class AdminAuth
|
|||||||
return ['locked' => false];
|
return ['locked' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registreert een mislukte inlogpoging voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Verhoogt de teller en actualiseert de timestamp van de laatste poging,
|
||||||
|
* waarna de gegevens worden weggeschreven naar het lockout-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function recordFailedAttempt(string $username): void
|
private function recordFailedAttempt(string $username): void
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -425,6 +852,16 @@ class AdminAuth
|
|||||||
file_put_contents($this->lockFile, json_encode($attempts));
|
file_put_contents($this->lockFile, json_encode($attempts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wist alle mislukte inlogpogingen voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Verwijdert de gebruiker uit het lockout-bestand en slaat dit op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function clearFailedAttempts(string $username): void
|
private function clearFailedAttempts(string $username): void
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -432,6 +869,29 @@ class AdminAuth
|
|||||||
file_put_contents($this->lockFile, json_encode($attempts));
|
file_put_contents($this->lockFile, json_encode($attempts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wist de lockout voor een gebruiker (publiek, gebruikt door CLI reset).
|
||||||
|
*
|
||||||
|
* Wrapper rond clearFailedAttempts die ook van buiten de class kan worden
|
||||||
|
* aangeroepen, bijvoorbeeld door een CLI-tool voor het resetten van vergrendelingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function clearLockout(string $username): void
|
||||||
|
{
|
||||||
|
$this->clearFailedAttempts($username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest alle mislukte inlogpogingen uit het lockout-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, array{count: int, last_attempt: int}> Map van gebruikersnaam naar poging-data.
|
||||||
|
*/
|
||||||
private function getFailedAttempts(): array
|
private function getFailedAttempts(): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->lockFile)) {
|
if (!file_exists($this->lockFile)) {
|
||||||
@@ -441,6 +901,18 @@ class AdminAuth
|
|||||||
return is_array($data) ? $data : [];
|
return is_array($data) ? $data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schrijft een bericht naar het logbestand met timestamp, level en IP-adres.
|
||||||
|
*
|
||||||
|
* Maakt de logdirectory aan indien nodig. Het IP-adres wordt via RequestLogger
|
||||||
|
* bepaald indien beschikbaar, anders via REMOTE_ADDR met een fallback.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $level Log-niveau (bijv. 'info', 'warning').
|
||||||
|
* @param string $message Bericht dat gelogd moet worden.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function log(string $level, string $message): void
|
private function log(string $level, string $message): void
|
||||||
{
|
{
|
||||||
$logFile = $this->config['log_file'];
|
$logFile = $this->config['log_file'];
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"admi":{"count":1,"last_attempt":1771257322}}
|
|
||||||
@@ -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', php: 'php' };
|
var modeMap = { md: 'markdown', html: 'htmlmixed', twig: '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,6 +42,7 @@
|
|||||||
{ 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' }
|
||||||
],
|
],
|
||||||
@@ -146,8 +147,48 @@
|
|||||||
insert(editor, '/**\n * \n */', 7);
|
insert(editor, '/**\n * \n */', 7);
|
||||||
break;
|
break;
|
||||||
case 'media':
|
case 'media':
|
||||||
var modal = new bootstrap.Modal(document.getElementById('mediaModal'));
|
var modalEl = document.getElementById('mediaModal');
|
||||||
if (modal) modal.show();
|
if (modalEl && window.bootstrap) {
|
||||||
|
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #f5f6fa; min-height: 100vh; }
|
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||||
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
|
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; overflow-y: auto; }
|
||||||
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
|
|
||||||
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
||||||
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
||||||
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
||||||
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
.admin-sidebar { width: 100%; height: auto; position: relative; }
|
||||||
.admin-main { margin-left: 0; }
|
.admin-main { margin-left: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -35,13 +34,13 @@
|
|||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="admin-sidebar d-flex flex-column">
|
<nav class="admin-sidebar" id="adminSidebar">
|
||||||
<div class="admin-brand">
|
<div class="admin-brand">
|
||||||
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav flex-column mt-2">
|
<ul class="nav flex-column mt-2">
|
||||||
{# Algemene sectie: Dashboard (altijd zichtbaar) + plugin items met section=general #}
|
{# Algemene sectie: Dashboard (altijd zichtbaar) + plugin items met section=general #}
|
||||||
{% set general_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'general' and item.route != 'dashboard') %}
|
{% set general_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'general' and item.route != 'dashboard' and (item.permission is not defined or item.permission == 'dashboard' or has_permission(item.permission)) and (item.required_roles is not defined or user_role == 'admin' or user_role in item.required_roles)) %}
|
||||||
<li class="nav-section">{{ ta.section_general|default('Algemeen') }}</li>
|
<li class="nav-section">{{ ta.section_general|default('Algemeen') }}</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
|
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
{% for item in general_plugins %}
|
{% for item in general_plugins %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
||||||
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
|
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ plugin_menu_label(item) }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -91,7 +90,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Systeem sectie: core admin items + plugin items met section=system #}
|
{# Systeem sectie: core admin items + plugin items met section=system #}
|
||||||
{% set system_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'system') %}
|
{% set system_plugins = plugin_admin_menu|filter(item => (item.section|default('general')) == 'system' and (item.permission is not defined or item.permission == 'dashboard' or has_permission(item.permission)) and (item.required_roles is not defined or user_role == 'admin' or user_role in item.required_roles)) %}
|
||||||
{% if has_permission('plugins') or has_permission('users') or has_permission('update') or system_plugins is not empty %}
|
{% if has_permission('plugins') or has_permission('users') or has_permission('update') or system_plugins is not empty %}
|
||||||
<li class="nav-section">{{ ta.section_system|default('Systeem') }}</li>
|
<li class="nav-section">{{ ta.section_system|default('Systeem') }}</li>
|
||||||
{% if has_permission('plugins') %}
|
{% if has_permission('plugins') %}
|
||||||
@@ -118,7 +117,7 @@
|
|||||||
{% for item in system_plugins %}
|
{% for item in system_plugins %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
<a class="nav-link {{ route == item.route or route starts with item.route ~ '/' ? 'active' : '' }}" href="/admin/{{ item.route }}">
|
||||||
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ item.label|default(item.route) }}
|
<i class="bi {{ item.icon|default('bi-puzzle') }}"></i> {{ plugin_menu_label(item) }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -134,7 +133,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="nav flex-column mt-auto mb-5">
|
<ul class="nav flex-column mt-3 mb-4">
|
||||||
|
{% if user_real_role == 'admin' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
|
||||||
|
<i class="bi bi-person-bounding-box"></i>
|
||||||
|
{% if has_role_override %}
|
||||||
|
{{ ta.role_testing|default('Testen') }}: {{ role_label(user_role) }}
|
||||||
|
{% else %}
|
||||||
|
{{ ta.role_switch|default('Rol wisselen') }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/" target="_blank">
|
<a class="nav-link" href="/" target="_blank">
|
||||||
<i class="bi bi-box-arrow-up-right"></i> {{ ta.view_website|default('Website bekijken') }}
|
<i class="bi bi-box-arrow-up-right"></i> {{ ta.view_website|default('Website bekijken') }}
|
||||||
@@ -148,10 +159,54 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
|
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
|
||||||
<br><small>{{ role_label(user_role)|default('') }}</small>
|
<br><small>{{ role_label(user_role)|default('') }}{% if has_role_override %} <span class="badge bg-warning text-dark">{{ ta.role_testing|default('Test') }}</span>{% endif %}</small>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{# Role switch modal (only rendered for real admins) #}
|
||||||
|
{% if user_real_role == 'admin' %}
|
||||||
|
<div class="modal fade" id="roleSwitchModal" tabindex="-1" aria-labelledby="roleSwitchModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/admin/{% if has_role_override %}role-reset{% else %}role-switch{% endif %}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="roleSwitchModalLabel">
|
||||||
|
<i class="bi bi-person-bounding-box"></i>
|
||||||
|
{% if has_role_override %}{{ ta.role_reset_title|default('Rol terugzetten') }}{% else %}{{ ta.role_switch_title|default('Rol wisselen') }}{% endif %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{% if has_role_override %}
|
||||||
|
<p>{{ ta.role_override_active|default('Je test momenteel als') }}: <strong>{{ role_label(user_role) }}</strong></p>
|
||||||
|
<p class="text-muted small">{{ ta.role_reset_help|default('Klik hieronder om terug te keren naar je admin rol.') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ ta.role_switch_help|default('Test de admin vanuit een andere rol. Je echte account blijft admin.') }}</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="role_switch_select" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
|
||||||
|
<select class="form-select" id="role_switch_select" name="new_role">
|
||||||
|
{% for roleKey, roleLabel in roles|default([]) %}
|
||||||
|
{% if roleKey != 'admin' %}
|
||||||
|
<option value="{{ roleKey }}">{{ roleLabel }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{% if has_role_override %}<i class="bi bi-arrow-counterclockwise"></i> {{ ta.role_reset_btn|default('Terug naar admin') }}{% else %}<i class="bi bi-check-lg"></i> {{ ta.role_switch_btn|default('Wissel naar rol') }}{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
{% if message is defined and message %}
|
{% if message is defined and message %}
|
||||||
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
||||||
@@ -163,6 +218,10 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% if needs_editor %}
|
||||||
|
{% include 'pages/_media-modal.twig' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||||
{% if needs_editor %}
|
{% if needs_editor %}
|
||||||
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
|
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if generated_password %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle-fill"></i> Eerste installatie</h6>
|
||||||
|
<p class="mb-2">Er is nog geen <code>admin.json</code> aanwezig. Een nieuw wachtwoord is gegenereerd voor de gebruiker <strong>admin</strong>:</p>
|
||||||
|
<p class="mb-2"><code class="fs-5">{{ generated_password }}</code></p>
|
||||||
|
<p class="mb-0 small">Log in met dit wachtwoord en wijzig het direct na login. Sla het veilig op; het wordt niet opnieuw getoond.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<form method="POST" action="/admin/login">
|
<form method="POST" action="/admin/login">
|
||||||
<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">
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{# 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-btn { background: transparent; border: 0; color: inherit; padding: .2rem .4rem; line-height: 1.4; flex: 0 0 auto; cursor: pointer; }
|
||||||
|
.editor-tree .tree-chevron-btn:hover { color: var(--bs-primary, #0d6efd); }
|
||||||
|
.editor-tree .tree-name { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.editor-tree .tree-name.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||||
|
.editor-tree .tree-root-link { display: flex; align-items: center; gap: .3rem; padding: .25rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; font-weight: 600; }
|
||||||
|
.editor-tree .tree-root-link:hover { background-color: rgba(13,110,253,.08); }
|
||||||
|
.editor-tree .tree-root-link.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); }
|
||||||
|
.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-move-btn,
|
||||||
|
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; background: transparent; border: 0; }
|
||||||
|
.editor-tree .tree-move-btn:hover,
|
||||||
|
.editor-tree .tree-delete-btn:hover { color: #0d6efd; }
|
||||||
|
.editor-tree .tree-delete-btn:hover { color: #dc3545; }
|
||||||
|
.editor-tree .tree-file-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; opacity: 0; transition: opacity .15s; }
|
||||||
|
.editor-tree li:hover > .tree-file-actions { opacity: 1; }
|
||||||
|
.editor-tree li > .tree-link { padding-right: 4rem; }
|
||||||
|
/* Drag-and-drop states */
|
||||||
|
.editor-tree li.tree-dragging { opacity: .5; }
|
||||||
|
.editor-tree .tree-drag-over { background-color: rgba(13,110,253,.18) !important; outline: 2px dashed var(--bs-primary, #0d6efd); outline-offset: -2px; border-radius: .25rem; }
|
||||||
|
/* Toast */
|
||||||
|
.tree-toast { border-radius: .375rem; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||||
|
/* Directory action buttons (new file/dir, rename) — content editor only */
|
||||||
|
.tree-dir-row { position: relative; display: flex; align-items: center; }
|
||||||
|
.tree-dir-row > .tree-name { padding-right: 3rem; }
|
||||||
|
.tree-dir-row > .tree-toggle { padding-right: 5rem; }
|
||||||
|
.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; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-layout { flex-direction: column; }
|
||||||
|
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||||
|
.plugin-file-tree .card-body { max-height: 260px; }
|
||||||
|
}
|
||||||
|
/* Plugin checkbox group (content editor) */
|
||||||
|
.plugin-checkbox-group { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.plugin-checkbox-group .form-check { margin-bottom: 0; }
|
||||||
|
.plugin-checkbox-group .form-check-label { font-size: .875rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{# 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)
|
||||||
|
- 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)
|
||||||
|
- 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 buttons (default true)
|
||||||
|
- treeFileMove: boolean; show per-file move/rename pencil button (default true)
|
||||||
|
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
|
||||||
|
- treeDirSelectRoute: if set, folder names are links to ?dir=<path> (map-detail pane)
|
||||||
|
- treeDirRenameRoute: if set, show a rename pencil per folder
|
||||||
|
- treeDeleteBase: POST URL for file deletion (scope-specific)
|
||||||
|
- treeScope: 'content' | 'theme' | 'plugin' (for tree-interactions.js)
|
||||||
|
- treeMoveUrl: AJAX endpoint for drag-drop move (e.g. '/admin/tree-move')
|
||||||
|
- treeScopeParam: extra query param string (e.g. 'plugin=MyPlugin&' or 'theme=default&' or '')
|
||||||
|
|
||||||
|
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 %}
|
||||||
|
{# Een bestand is alleen actief wanneer er geen map geselecteerd is
|
||||||
|
(een map-selectie opent het map-detail paneel, geen bestand). #}
|
||||||
|
{% set isActive = n.path == opts.relFile and opts.selectedDir|default('') == '' %}
|
||||||
|
{# Markeer de oudermap "bevat actieve selectie" voor auto-uitklappen:
|
||||||
|
bij een geselecteerd bestand OF een geselecteerde (sub)map. #}
|
||||||
|
{% set activePath = opts.selectedDir|default('') != '' ? opts.selectedDir : opts.relFile %}
|
||||||
|
{% set containsActive = n.is_dir and activePath != '' and activePath 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 data-path="{{ n.path }}" data-isdir="{{ n.is_dir ? '1' : '0' }}">
|
||||||
|
{% if n.is_dir %}
|
||||||
|
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||||
|
<div class="tree-dir-row" data-dir="{{ n.path }}">
|
||||||
|
{# Chevron: expand/collapse only (zonder navigatie) #}
|
||||||
|
<button type="button" class="tree-chevron-btn" data-bs-toggle="collapse" data-bs-target="#{{ collapseId }}" aria-expanded="{{ containsActive ? 'true' : 'false' }}" title="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}" aria-label="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}">
|
||||||
|
<i class="bi {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
|
||||||
|
</button>
|
||||||
|
{# Mapnaam: selecteer de map. Voor content gaat dit naar ?dir= (map-detail paneel rechts);
|
||||||
|
voor plugins/themes is het een niet-navigerende toggle (legacy gedrag). #}
|
||||||
|
{% if opts.treeDirSelectRoute %}
|
||||||
|
<a href="{{ opts.treeDirSelectRoute }}{% if '?' in opts.treeDirSelectRoute %}&{% else %}?{% endif %}dir={{ n.path|url_encode }}" class="tree-link tree-name {{ containsActive ? 'is-active' : '' }} {{ opts.selectedDir == n.path ? 'is-selected' : '' }}" title="{{ n.path }}">
|
||||||
|
<i class="bi {{ icon }}"></i>
|
||||||
|
<span class="text-truncate">{{ n.name }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<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 {{ icon }}"></i>
|
||||||
|
<span class="text-truncate">{{ n.name }}</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% 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="{{ opts.newFileModalId|default('#newFileModal') }}" data-in-dir="{{ n.path }}">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i>
|
||||||
|
</button>
|
||||||
|
{% if opts.newDirModalId is defined and opts.newDirModalId %}
|
||||||
|
<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="{{ opts.newDirModalId }}" data-in-dir="{{ n.path }}">
|
||||||
|
<i class="bi bi-folder-plus"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if opts.treeDirRenameRoute %}
|
||||||
|
<a href="{{ opts.treeDirRenameRoute }}{% if '?' in opts.treeDirRenameRoute %}&{% else %}?{% endif %}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>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
||||||
|
<span class="tree-file-actions">
|
||||||
|
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
|
||||||
|
{% set showFileMove = opts.treeFileMove is defined ? opts.treeFileMove : true %}
|
||||||
|
{% if showFileMove %}
|
||||||
|
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
|
||||||
|
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen/Verplaatsen') }}" aria-label="{{ ta.rename|default('Hernoemen/Verplaatsen') }}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}" data-path="{{ n.path }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% 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"
|
||||||
|
data-csrf="{{ csrf_token|default('') }}"
|
||||||
|
data-scope="{{ treeScope|default('content') }}"
|
||||||
|
data-move-url="{{ treeMoveUrl|default('') }}"
|
||||||
|
data-delete-base="{{ treeDeleteBase|default('') }}"
|
||||||
|
data-new-file-modal="{{ newFileModalId|default('#newFileModal') }}"
|
||||||
|
data-new-dir-modal="{{ newDirModalId|default('#newDirModal') }}"
|
||||||
|
data-scope-param="{{ treeScopeParam|default('') }}"
|
||||||
|
data-edit-base="{{ treeRouteBase|default('') }}">
|
||||||
|
{% if treeDirSelectRoute is defined and treeDirSelectRoute is not empty %}
|
||||||
|
{# Klikbare content-root: selecteert de root map (?dir= leeg).
|
||||||
|
data-dir="" maakt de root ook een drag-drop doel (lege string = base dir). #}
|
||||||
|
<a href="{{ treeDirSelectRoute }}" class="tree-link tree-root-link {{ selectedDir|default('') == '' ? 'is-selected' : '' }}" data-dir="" title="content">
|
||||||
|
<i class="bi bi-folder2-open"></i>
|
||||||
|
<span class="text-truncate">{{ treeRootLabel|default('content') }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if files is empty %}
|
||||||
|
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
|
||||||
|
{% else %}
|
||||||
|
{{ tree_macros.tree(files, {
|
||||||
|
'relFile': relFile,
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'editableExts': editableExts,
|
||||||
|
'treeIdPrefix': treeIdPrefix,
|
||||||
|
'treeRouteBase': treeRouteBase,
|
||||||
|
'treeRouteParams': treeRouteParams,
|
||||||
|
'treeMoveRoute': treeMoveRoute,
|
||||||
|
'treeMoveParams': treeMoveParams,
|
||||||
|
'treeDirActions': treeDirActions is defined ? treeDirActions : true,
|
||||||
|
'treeDirSelectRoute': treeDirSelectRoute|default(''),
|
||||||
|
'treeDirRenameRoute': treeDirRenameRoute|default(''),
|
||||||
|
'treeFileMove': treeFileMove is defined ? treeFileMove : true,
|
||||||
|
'treeHideActionsFor': treeHideActionsFor|default([]),
|
||||||
|
'newFileModalId': newFileModalId|default('#newFileModal'),
|
||||||
|
'newDirModalId': newDirModalId|default(''),
|
||||||
|
}) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/admin/assets/js/tree-interactions.js"></script>
|
||||||
|
<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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make all tree <li> nodes draggable (for drag-and-drop move)
|
||||||
|
Array.prototype.forEach.call(tree.querySelectorAll('li[data-path]'), function (li) {
|
||||||
|
li.setAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
{# Reusable media modal, included by admin.twig when needs_editor is true.
|
||||||
|
The editor toolbar "media" button opens this modal. It fetches the list
|
||||||
|
of media files from /admin/media-list (JSON) and renders them as a
|
||||||
|
collapsible file tree (consistent with the content/plugin/theme editors).
|
||||||
|
On click of a file node, a snippet is inserted into the active CodeMirror
|
||||||
|
editor at the cursor.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Default (content editor): fetches /admin/media-list
|
||||||
|
which scans content/ (URLs are prefixed with /-media/).
|
||||||
|
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
|
||||||
|
which scans plugins/<name>/assets/ instead of content/.
|
||||||
|
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
|
||||||
|
which scans themes/<name>/assets/ instead of content/.
|
||||||
|
|
||||||
|
The snippet format depends on the editor's current mode (data-ext):
|
||||||
|
- markdown (md): 
|
||||||
|
- html/php: <img src="url" alt="name">
|
||||||
|
- other (css, json, js, ...): the raw URL #}
|
||||||
|
<div class="modal fade" id="mediaModal" tabindex="-1" aria-labelledby="mediaModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="mediaModalLabel"><i class="bi bi-images"></i> {{ ta.media_insert|default('Media invoegen') }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-group input-group-sm mb-3">
|
||||||
|
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
|
||||||
|
<input type="search" id="mediaModalFilter" class="form-control" placeholder="{{ ta.media_filter|default('Filter op bestandsnaam...') }}" autocomplete="off" aria-label="{{ ta.media_filter|default('Filter') }}">
|
||||||
|
</div>
|
||||||
|
<div id="mediaModalTree" class="media-tree-wrap">
|
||||||
|
<div class="text-center text-muted py-4" id="mediaModalLoading">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-muted py-4 d-none" id="mediaModalEmpty">
|
||||||
|
<i class="bi bi-image display-6 d-block mb-2"></i>
|
||||||
|
{{ mediaEmptyText|default('Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-danger py-4 d-none" id="mediaModalError">
|
||||||
|
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
|
||||||
|
<span id="mediaModalErrorText"></span>
|
||||||
|
</div>
|
||||||
|
{# Tree populated by JS #}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Media modal tree — self-contained so the modal works on any editor page. */
|
||||||
|
.media-tree-wrap { max-height: 60vh; overflow-y: auto; }
|
||||||
|
.media-tree { list-style: none; padding-left: 0; margin: 0; }
|
||||||
|
.media-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
|
||||||
|
.media-tree li { padding: 0; position: relative; }
|
||||||
|
.media-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; cursor: pointer; }
|
||||||
|
.media-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
|
||||||
|
.media-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
|
||||||
|
.media-tree .tree-chevron { flex: 0 0 auto; }
|
||||||
|
.media-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||||
|
.media-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.media-tree .badge-ext { font-size: .6rem; margin-left: auto; }
|
||||||
|
.media-tree .media-thumb { width: 1.1rem; height: 1.1rem; object-fit: cover; flex: 0 0 auto; border-radius: .2rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var loaded = false;
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
function formatSnippet(url, name, ext, mode) {
|
||||||
|
if (mode === 'markdown') {
|
||||||
|
return '![' + name.replace(/[\[\]]/g, '') + '](' + url + ')';
|
||||||
|
}
|
||||||
|
if (mode === 'html' || mode === 'htmlmixed' || mode === 'php') {
|
||||||
|
var imgExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
||||||
|
if (imgExts.indexOf(ext) !== -1) {
|
||||||
|
return '<img src="' + url + '" alt="' + name.replace(/"/g, '"') + '">';
|
||||||
|
}
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
return '<a href="' + url + '">' + name + '</a>';
|
||||||
|
}
|
||||||
|
if (['mp4', 'webm', 'mov', 'ogg'].indexOf(ext) !== -1) {
|
||||||
|
return '<video src="' + url + '" controls></video>';
|
||||||
|
}
|
||||||
|
if (['mp3', 'wav'].indexOf(ext) !== -1) {
|
||||||
|
return '<audio src="' + url + '" controls></audio>';
|
||||||
|
}
|
||||||
|
return '<a href="' + url + '">' + name + '</a>';
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeForExt(ext) {
|
||||||
|
var map = { md: 'markdown', html: 'htmlmixed', php: 'php', css: 'css', scss: 'css', js: 'javascript', json: 'javascript' };
|
||||||
|
return map[ext] || 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconFor(ext, isImage) {
|
||||||
|
if (isImage) return 'bi-file-image text-info';
|
||||||
|
if (ext === 'pdf') return 'bi-file-pdf text-danger';
|
||||||
|
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
|
||||||
|
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
|
||||||
|
return 'bi-file text-muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a nested tree from a flat list of {path, name, ...} items.
|
||||||
|
function buildTree(flat) {
|
||||||
|
var root = { name: '', path: '', children: {}, isDir: true };
|
||||||
|
flat.forEach(function (item) {
|
||||||
|
var parts = item.path.split('/');
|
||||||
|
var node = root;
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var part = parts[i];
|
||||||
|
if (part === '') continue;
|
||||||
|
var isLast = i === parts.length - 1;
|
||||||
|
if (!node.children[part]) {
|
||||||
|
node.children[part] = {
|
||||||
|
name: part, path: parts.slice(0, i + 1).join('/'),
|
||||||
|
children: {}, isDir: !isLast
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isLast) {
|
||||||
|
node.children[part].item = item;
|
||||||
|
node.children[part].isDir = false;
|
||||||
|
}
|
||||||
|
node = node.children[part];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Convert children maps to sorted arrays (dirs first, then alpha)
|
||||||
|
function finalize(n) {
|
||||||
|
var arr = Object.keys(n.children).map(function (k) { return n.children[k]; });
|
||||||
|
arr.sort(function (a, b) {
|
||||||
|
if (a.isDir && !b.isDir) return -1;
|
||||||
|
if (!a.isDir && b.isDir) return 1;
|
||||||
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||||
|
});
|
||||||
|
n.childArr = arr;
|
||||||
|
arr.forEach(finalize);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
finalize(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(node, q) {
|
||||||
|
if (!q) return true;
|
||||||
|
if (node.item && node.item.name.toLowerCase().indexOf(q) !== -1) return true;
|
||||||
|
if (node.childArr) {
|
||||||
|
for (var i = 0; i < node.childArr.length; i++) {
|
||||||
|
if (matchesFilter(node.childArr[i], q)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var idCounter = 0;
|
||||||
|
|
||||||
|
function renderNode(node, container, q) {
|
||||||
|
var ul = document.createElement('ul');
|
||||||
|
ul.className = 'media-tree';
|
||||||
|
node.childArr.forEach(function (child) {
|
||||||
|
if (!matchesFilter(child, q)) return;
|
||||||
|
var li = document.createElement('li');
|
||||||
|
if (child.isDir) {
|
||||||
|
var collapseId = 'media-tree-' + (++idCounter);
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'tree-link tree-toggle btn-toggle collapsed';
|
||||||
|
btn.setAttribute('data-bs-toggle', 'collapse');
|
||||||
|
btn.setAttribute('data-bs-target', '#' + collapseId);
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.title = child.path;
|
||||||
|
btn.innerHTML = '<i class="bi bi-chevron-right tree-chevron"></i>' +
|
||||||
|
'<i class="bi bi-folder-fill text-warning"></i>' +
|
||||||
|
'<span class="text-truncate">' + escapeHtml(child.name) + '</span>';
|
||||||
|
li.appendChild(btn);
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.id = collapseId;
|
||||||
|
wrap.className = 'tree-collapse collapse';
|
||||||
|
renderNode(child, wrap, q);
|
||||||
|
li.appendChild(wrap);
|
||||||
|
} else {
|
||||||
|
var item = child.item;
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.className = 'tree-link';
|
||||||
|
a.title = item.path + ' (' + item.size + ')';
|
||||||
|
var thumb = '';
|
||||||
|
if (item.is_image) {
|
||||||
|
thumb = '<img class="media-thumb" src="' + item.url + '" alt="" loading="lazy">';
|
||||||
|
} else {
|
||||||
|
thumb = '<i class="bi ' + iconFor(item.extension, item.is_image) + '"></i>';
|
||||||
|
}
|
||||||
|
a.innerHTML = '<span class="tree-chevron-spacer"></span>' + thumb +
|
||||||
|
'<span class="text-truncate">' + escapeHtml(item.name) + '</span>' +
|
||||||
|
'<span class="badge bg-secondary badge-ext">' + item.extension.toUpperCase() + '</span>';
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertItem(item);
|
||||||
|
});
|
||||||
|
li.appendChild(a);
|
||||||
|
}
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
container.appendChild(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertItem(item) {
|
||||||
|
var editor = window.codeMirrorEditor;
|
||||||
|
if (!editor) return;
|
||||||
|
var ext = (document.getElementById('editor-textarea') || {}).dataset;
|
||||||
|
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
|
||||||
|
var mode = modeForExt(fileExt);
|
||||||
|
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
|
||||||
|
editor.replaceSelection(snippet);
|
||||||
|
editor.focus();
|
||||||
|
var modalEl = document.getElementById('mediaModal');
|
||||||
|
if (window.bootstrap && modalEl) {
|
||||||
|
var inst = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (inst) inst.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
var wrap = document.getElementById('mediaModalTree');
|
||||||
|
if (!wrap) return;
|
||||||
|
// Clear previous tree (keep placeholders)
|
||||||
|
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
|
||||||
|
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
|
||||||
|
var tree = buildTree(items);
|
||||||
|
var shown = tree.childArr.length;
|
||||||
|
if (shown > 0) {
|
||||||
|
// When filtering, auto-expand all dirs by rendering without collapsed class
|
||||||
|
renderNode(tree, wrap, q);
|
||||||
|
}
|
||||||
|
var loading = document.getElementById('mediaModalLoading');
|
||||||
|
var empty = document.getElementById('mediaModalEmpty');
|
||||||
|
if (loading) loading.classList.add('d-none');
|
||||||
|
if (empty) empty.classList.toggle('d-none', shown > 0);
|
||||||
|
// When filtering, expand all folders for visibility
|
||||||
|
if (q) {
|
||||||
|
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-collapse'), function (el) { el.classList.add('show'); });
|
||||||
|
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-toggle.btn-toggle'), function (el) {
|
||||||
|
el.classList.remove('collapsed');
|
||||||
|
el.setAttribute('aria-expanded', 'true');
|
||||||
|
var icon = el.querySelector('.tree-chevron');
|
||||||
|
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (loaded) { renderTree(); return; }
|
||||||
|
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
|
||||||
|
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
|
||||||
|
var url = '/admin/media-list';
|
||||||
|
if (plugin) { url += '?plugin=' + encodeURIComponent(plugin); }
|
||||||
|
else if (theme) { url += '?theme=' + encodeURIComponent(theme); }
|
||||||
|
fetch(url, { credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
items = Array.isArray(data) ? data : [];
|
||||||
|
loaded = true;
|
||||||
|
renderTree();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
var loading = document.getElementById('mediaModalLoading');
|
||||||
|
if (loading) loading.classList.add('d-none');
|
||||||
|
var errEl = document.getElementById('mediaModalError');
|
||||||
|
var errTxt = document.getElementById('mediaModalErrorText');
|
||||||
|
if (errEl) errEl.classList.remove('d-none');
|
||||||
|
if (errTxt) errTxt.textContent = String(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var modalEl = document.getElementById('mediaModal');
|
||||||
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('show.bs.modal', load);
|
||||||
|
}
|
||||||
|
var filter = document.getElementById('mediaModalFilter');
|
||||||
|
if (filter) {
|
||||||
|
filter.addEventListener('input', renderTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chevron rotation: swap icon class between chevron-right and chevron-down
|
||||||
|
var mediaTreeWrap = document.getElementById('mediaModalTree');
|
||||||
|
if (mediaTreeWrap) {
|
||||||
|
mediaTreeWrap.addEventListener('shown.bs.collapse', function (e) {
|
||||||
|
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
||||||
|
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); } }
|
||||||
|
});
|
||||||
|
mediaTreeWrap.addEventListener('hidden.bs.collapse', function (e) {
|
||||||
|
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
|
||||||
|
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% 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,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{% include 'pages/_editor-styles.twig' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||||
|
|
||||||
@@ -11,12 +15,8 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
<label class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
<div class="input-group">
|
<p class="form-control-plaintext mb-0"><code>{{ fileName }}</code></p>
|
||||||
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
|
|
||||||
<span class="input-group-text">.{{ fileExt }}</span>
|
|
||||||
</div>
|
|
||||||
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
|
||||||
</div>
|
</div>
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -29,42 +29,62 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if availablePlugins is not empty %}
|
{% if availablePlugins is not empty %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="plugin-checkbox-group">
|
||||||
{% for plugin in availablePlugins %}
|
{% for plugin in availablePlugins %}
|
||||||
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
<div class="form-check">
|
||||||
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if isEditable and (currentCreated or currentEdited) %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label text-muted"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
|
||||||
|
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</form>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
<button type="submit" form="editor-form" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
||||||
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||||
|
<form method="POST" action="/admin/content-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="file" value="{{ file }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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;
|
||||||
|
|
||||||
@@ -78,10 +98,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
backBtn.classList.add('btn-outline-danger');
|
backBtn.classList.add('btn-outline-danger');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filenameInput) {
|
|
||||||
filenameInput.addEventListener('input', markChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the layout value in the editor's frontmatter when the select changes
|
// Update the layout value in the editor's frontmatter when the select changes
|
||||||
if (layoutSelect) {
|
if (layoutSelect) {
|
||||||
layoutSelect.addEventListener('change', function () {
|
layoutSelect.addEventListener('change', function () {
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
{% 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,
|
||||||
|
'treeIdPrefix': 'content-tree',
|
||||||
|
'treeHeader': ta.content_files|default('Content bestanden'),
|
||||||
|
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
||||||
|
'treeRouteBase': '/admin/content',
|
||||||
|
'treeRouteParams': '',
|
||||||
|
'treeMoveRoute': '/admin/content-file-move',
|
||||||
|
'treeMoveParams': '',
|
||||||
|
'treeDirActions': true,
|
||||||
|
'treeDirSelectRoute': '/admin/content',
|
||||||
|
'treeDirRenameRoute': '',
|
||||||
|
'treeFileMove': false,
|
||||||
|
'treeHideActionsFor': [],
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'treeRootLabel': 'content',
|
||||||
|
'treeScope': 'content',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/content-file-delete',
|
||||||
|
'treeScopeParam': '',
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{# Editor / map-detail main panel #}
|
||||||
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# ── Map detail paneel ( rechts) ── tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-folder2-open"></i> content</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% 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?dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? 'content' : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">content/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Mapnaam bewerkbaar + Opslaan = hernoem (geen apart potlood-icoon in de boom meer) #}
|
||||||
|
<form method="POST" action="/admin/content?{% if selectedDir %}dir={{ selectedDir|url_encode }}{% endif %}" id="dir-rename-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="rename_dir">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<label for="dirname" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" id="dirname" name="newname" value="{{ selectedDirIsRoot ? '' : selectedDirName }}" placeholder="{{ ta.folder_name_placeholder|default('mapnaam') }}" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||||
|
<button type="submit" class="btn btn-primary" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">{{ ta.folder_rename_help|default('Bewerk de mapnaam en klik opslaan om te hernoemen.') }}</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload — opereren op de geselecteerde map #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewFileBtn">
|
||||||
|
<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" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewDirBtn">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm" id="mapPaneelUploadBtn">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
|
</button>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<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="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Korte inhoud-samenvatting van de geselecteerde map #}
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% 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>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" form="editor-form" 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>
|
||||||
|
<form method="POST" action="/admin/content-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% 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 of map uit de zijbalk.') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
|
</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="" data-in-dir-field>
|
||||||
|
<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="" data-in-dir-field>
|
||||||
|
<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>
|
||||||
|
// Mapselectie + upload/new file/new dir binding aan de geselecteerde map.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var newFileModal = document.getElementById('newFileModal');
|
||||||
|
var newDirModal = document.getElementById('newDirModal');
|
||||||
|
var newFileBtnTop = document.getElementById('newFileBtnTop');
|
||||||
|
var newDirBtnTop = document.getElementById('newDirBtnTop');
|
||||||
|
var uploadDir = document.getElementById('contentUploadDir');
|
||||||
|
|
||||||
|
// Track the selected directory (server-side via ?dir=). This drives the
|
||||||
|
// top toolbar buttons and the upload form target.
|
||||||
|
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);
|
||||||
|
if (uploadDir) uploadDir.value = selectedDir;
|
||||||
|
}
|
||||||
|
updateTopButtons(selectedDir);
|
||||||
|
|
||||||
|
var tree = document.getElementById('editorFileTree');
|
||||||
|
if (tree) {
|
||||||
|
// Chevron-kliks mogen niet doorbubbellen naar de mapnaam-link (die navigeert).
|
||||||
|
tree.addEventListener('click', function (e) {
|
||||||
|
var chevronBtn = e.target.closest('.tree-chevron-btn');
|
||||||
|
if (chevronBtn) { e.stopPropagation(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || '';
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,30 +1,45 @@
|
|||||||
{% extends "layouts/admin.twig" %}
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
{% 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 title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<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>
|
<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 / 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" class="card shadow-sm">
|
<form method="post" action="/admin/content-file-move?file={{ relFile|url_encode }}" 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') }} <strong>{{ itemName }}</strong> {{ ta.move_to|default('naar:') }}
|
{{ ta.move_prefix|default('Verplaats / hernoem') }} <strong>{{ itemName }}{{ itemExt }}</strong>
|
||||||
|
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }}</label>
|
<label for="new_name" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="new_name" name="new_name" value="{{ itemName }}" required>
|
||||||
|
<span class="input-group-text">{{ itemExt }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div 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 }}</option>
|
<option value="{{ directory }}" {{ directory == itemDir ? 'selected' : '' }}>{{ directory == '' ? '(content root)' : directory }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white">
|
<div class="card-footer bg-white">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/content?dir={{ itemDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</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?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<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>
|
||||||
@@ -24,7 +27,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?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
<form method="POST" action="/admin/content-list?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>
|
||||||
@@ -43,7 +46,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"><i class="bi bi-house"></i></a></li>
|
<li class="breadcrumb-item"><a href="/admin/content-list"><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 %}
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
{% if crumbPath == subdir %}
|
{% if crumbPath == subdir %}
|
||||||
{{ crumb }}
|
{{ crumb }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
<a href="/admin/content-list?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -86,7 +89,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?dir={{ item.path|url_encode }}">
|
<a href="/admin/content-list?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 %}
|
||||||
|
|||||||
@@ -3,18 +3,98 @@
|
|||||||
{% block title %}{{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
{% block title %}{{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4"><i class="bi bi-plug"></i> {{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }}</h2>
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-gear"></i> {{ ta.plugin_config|default('Plugin Configuratie: ') }}{{ pluginName }}</h2>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" class="card shadow-sm">
|
<div class="row g-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<div class="col-lg-8">
|
||||||
<div class="card-body">
|
<div class="card shadow-sm">
|
||||||
<textarea name="config" id="config-textarea" class="form-control font-monospace" rows="20">{{ pluginConfig }}</textarea>
|
<div class="card-header">
|
||||||
|
<i class="bi bi-sliders"></i> {{ ta.plugin_settings|default('Instellingen') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if settingsSchema is empty %}
|
||||||
|
<p class="text-muted mb-0">{{ ta.plugin_no_settings|default('Deze plugin heeft geen instelbare configuratie.') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="/admin/plugins-config?plugin={{ pluginName|url_encode }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
{% for setting in settingsSchema %}
|
||||||
|
{% set settingLabel = setting.resolved_label|default('') ? setting.resolved_label : (setting.label|default(setting.key)) %}
|
||||||
|
{% set settingHelp = setting.resolved_help|default('') ? setting.resolved_help : (setting.help|default('')) %}
|
||||||
|
{% set settingOptions = setting.resolved_options is not null ? setting.resolved_options : (setting.options|default([])) %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold" for="setting-{{ setting.key }}">{{ settingLabel }}</label>
|
||||||
|
{% set currentValue = resolvedConfig[setting.key]|default(setting.default) %}
|
||||||
|
{% if setting.type == 'select' %}
|
||||||
|
<select class="form-select" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}">
|
||||||
|
{% for optValue, optLabel in settingOptions %}
|
||||||
|
<option value="{{ optValue }}" {{ currentValue == optValue ? 'selected' : '' }}>{{ optLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elseif setting.type == 'multi-select' %}
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for optValue, optLabel in settingOptions %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="setting-{{ setting.key }}-{{ optValue }}" name="setting_{{ setting.key }}[]" value="{{ optValue }}" {{ optValue in currentValue ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting-{{ setting.key }}-{{ optValue }}">{{ optLabel }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elseif setting.type == 'checkbox' %}
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" {{ currentValue ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting-{{ setting.key }}">{{ settingLabel }}</label>
|
||||||
|
</div>
|
||||||
|
{% elseif setting.type == 'number' %}
|
||||||
|
<input type="number" class="form-control" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" value="{{ currentValue }}" min="1">
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="setting-{{ setting.key }}" name="setting_{{ setting.key }}" value="{{ currentValue }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if settingHelp %}
|
||||||
|
<small class="form-text text-muted">{{ settingHelp }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
<div class="col-lg-4">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<div class="card shadow-sm mb-4">
|
||||||
</button>
|
<div class="card-header">
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<i class="bi bi-info-circle"></i> {{ ta.plugin_info|default('Plugin informatie') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr><td class="text-muted">{{ ta.plugin_name|default('Naam') }}</td><td>{{ pluginJson.name|default(pluginName) }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.version|default('Versie') }}</td><td>{{ pluginJson.version|default('-') }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.author|default('Auteur') }}</td><td>{{ pluginJson.author|default('-') }}</td></tr>
|
||||||
|
<tr><td class="text-muted">{{ ta.type|default('Type') }}</td><td><span class="badge bg-{{ pluginJson.type == 'system' ? 'primary' : 'success' }}">{{ pluginJson.type|default('content') }}</span></td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if pluginJson.description %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-card-text"></i> {{ ta.description|default('Beschrijving') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">{{ pluginJson.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -5,33 +5,249 @@
|
|||||||
{% 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' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% 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 flex-wrap gap-2">
|
||||||
<h2><i class="bi bi-pencil"></i> {{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }}</h2>
|
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }}</h2>
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
<div class="d-flex gap-2">
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#pluginUploadForm">
|
||||||
</a>
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file|default('Nieuw bestand') }}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
{# Upload form (collapsed by default) #}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<div class="collapse mb-3" id="pluginUploadForm">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
<form method="POST" action="/admin/plugins-file-upload" enctype="multipart/form-data">
|
||||||
<div class="editor-wrapper">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<textarea name="content" id="editor-textarea" data-ext="php">{{ pluginContent }}</textarea>
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
</div>
|
<input type="hidden" name="dir" value="" id="pluginUploadDir">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pluginUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
|
||||||
|
<input type="file" class="form-control" id="pluginUploadFile" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav,.css,.scss,.js,.json,.html,.md">
|
||||||
|
<small class="form-text text-muted">{{ ta.plugin_upload_help|default('Bestanden worden geüpload naar assets/ van deze plugin. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.') }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 mt-3">
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<div class="editor-layout">
|
||||||
</button>
|
{# File tree sidebar (shared partial) #}
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
{% include 'pages/_file-tree.twig' with {
|
||||||
|
'files': files,
|
||||||
|
'relFile': relFile,
|
||||||
|
'editableExts': editableExts,
|
||||||
|
'treeIdPrefix': 'plugin-tree',
|
||||||
|
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
||||||
|
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
||||||
|
'treeRouteBase': '/admin/plugins-edit',
|
||||||
|
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
|
'treeMoveRoute': '/admin/plugins-file-move',
|
||||||
|
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
|
'treeDirActions': true,
|
||||||
|
'treeHideActionsFor': [],
|
||||||
|
'treeDirSelectRoute': '/admin/plugins-edit?plugin=' ~ pluginName|url_encode,
|
||||||
|
'treeDirRenameRoute': '/admin/plugins-dir-rename-in?plugin=' ~ pluginName|url_encode,
|
||||||
|
'treeRootLabel': pluginName,
|
||||||
|
'treeScope': 'plugin',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/plugins-file-delete',
|
||||||
|
'treeScopeParam': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{# Editor main panel #}
|
||||||
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ pluginName }}</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? pluginName : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ pluginName }}/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<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" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<form method="POST" action="/admin/plugins-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="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% if relFile %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> {{ pluginName }}</li>
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% set parts = relFile|split('/') %}
|
||||||
|
{% for part in parts %}
|
||||||
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if isEditable %}
|
||||||
|
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" id="editor-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" form="editor-form" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
<form method="POST" action="/admin/plugins-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
|
||||||
|
{% if relFile %}
|
||||||
|
{{ ta.plugin_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
||||||
|
{% else %}
|
||||||
|
{{ ta.plugin_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
{# New file modal #}
|
||||||
|
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="new_file">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newFilenameInput" class="form-label">{{ ta.plugin_file_path|default('Bestandspad binnen plugin') }}</label>
|
||||||
|
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. helper.php of assets/css/extra.css" required autofocus>
|
||||||
|
<div class="form-text">{{ ta.plugin_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt.') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 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/plugins-dir-create-in">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
|
<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>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
|
||||||
|
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/plugins-file-move?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="card shadow-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
|
||||||
|
{% if itemDir %}<span class="text-muted">({{ pluginName }}/{{ itemDir }})</span>{% else %}<span class="text-muted">({{ pluginName }}/)</span>{% endif %}
|
||||||
|
{{ ta.move_to|default('naar:') }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">({{ pluginName }}/)</small></label>
|
||||||
|
<select class="form-select" id="destination" name="destination" required>
|
||||||
|
{% for directory in directories %}
|
||||||
|
<option value="{{ directory }}">{{ directory == '' ? '(plugin root)' : directory }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -38,31 +38,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white">
|
<div class="card-footer bg-white">
|
||||||
<div class="btn-group w-100" role="group">
|
<div class="btn-group w-100" role="group" aria-label="{{ ta.plugin_actions|default('Plugin acties') }}">
|
||||||
{% if plugin.protected or plugin.essential %}
|
{% if plugin.protected or plugin.essential %}
|
||||||
<span class="btn btn-sm btn-outline-secondary disabled" title="{{ ta.essential_title|default('Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd') }}">
|
<span class="btn btn-sm btn-outline-secondary disabled" title="{{ ta.essential_title|default('Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd') }}" aria-label="{{ ta.essential|default('Essentieel') }}">
|
||||||
<i class="bi bi-shield-check"></i> {{ ta.essential|default('Essentieel') }}
|
<i class="bi bi-shield-check"></i>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary">
|
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit|default('Bewerken') }}" aria-label="{{ ta.edit|default('Bewerken') }}">
|
||||||
<i class="bi bi-pencil"></i> {{ ta.edit|default('Bewerken') }}
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if plugin.hasConfig %}
|
{% if plugin.hasConfig %}
|
||||||
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info">
|
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info" title="{{ ta.config|default('Configuratie') }}" aria-label="{{ ta.config|default('Configuratie') }}">
|
||||||
<i class="bi bi-gear"></i> {{ ta.config|default('Config') }}
|
<i class="bi bi-gear"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/admin/plugins-toggle" class="d-inline">
|
<form method="POST" action="/admin/plugins-toggle" 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="plugin" value="{{ plugin.name }}">
|
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}">
|
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}" title="{{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}" aria-label="{{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}">
|
||||||
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i> {{ plugin.enabled ? ta.deactivate|default('Deactiveren') : ta.activate|default('Activeren') }}
|
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_plugin|default('Weet je zeker dat je deze plugin wilt verwijderen?') }}')">
|
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_plugin|default('Weet je zeker dat je deze plugin wilt verwijderen?') }}')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
{% 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>
|
||||||
|
<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>
|
||||||
|
<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,
|
||||||
|
'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 ~ '&',
|
||||||
|
'treeMoveRoute': '/admin/theme-file-move',
|
||||||
|
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
|
'treeDirActions': true,
|
||||||
|
'treeHideActionsFor': ['theme.json'],
|
||||||
|
'treeDirSelectRoute': '/admin/theme-edit?theme=' ~ themeName|url_encode,
|
||||||
|
'treeDirRenameRoute': '/admin/theme-dir-rename-in?theme=' ~ themeName|url_encode,
|
||||||
|
'treeRootLabel': themeName,
|
||||||
|
'treeScope': 'theme',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/theme-file-delete',
|
||||||
|
'treeScopeParam': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{# Editor main panel #}
|
||||||
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ themeName }}</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% 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 }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? themeName : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ themeName }}/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<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" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<form method="POST" action="/admin/theme-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="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% 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>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" form="editor-form" 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>
|
||||||
|
{% if relFile != 'theme.json' %}
|
||||||
|
<form method="POST" action="/admin/theme-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
|
</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">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# 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/theme-dir-create-in">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
|
||||||
|
<a href="/admin/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,6 +13,16 @@
|
|||||||
<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,17 +5,9 @@
|
|||||||
{% 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>
|
||||||
<div>
|
<a href="/admin/theme-new" class="btn btn-primary btn-sm">
|
||||||
<form method="POST" action="/admin/theme" class="d-inline">
|
<i class="bi bi-plus-lg"></i> {{ ta.new_theme|default('Nieuw thema') }}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
</a>
|
||||||
<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') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
@@ -23,35 +15,68 @@
|
|||||||
<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">{{ theme.title|default(theme.name) }}</span>
|
<span class="fw-bold">
|
||||||
{% if theme.active %}
|
{% 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 %}
|
||||||
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
{{ theme.title|default(theme.name) }}
|
||||||
{% endif %}
|
</span>
|
||||||
|
<div class="d-flex gap-1 flex-wrap">
|
||||||
|
{% if theme.active %}
|
||||||
|
<span class="badge bg-success">{{ ta.active|default('Actief') }}</span>
|
||||||
|
{% 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">{{ ta.name_label|default('Naam: ') }}{{ theme.name }}</p>
|
<p class="text-muted small mb-1">{{ ta.name_label|default('Naam: ') }}<code>{{ theme.name }}</code></p>
|
||||||
{% if theme.default_layout %}
|
{% if theme.config.default_template %}
|
||||||
<p class="text-muted small">{{ ta.default_layout_label|default('Default layout: ') }}{{ theme.default_layout }}</p>
|
<p class="text-muted small mb-1">{{ ta.default_layout_label|default('Default layout: ') }}{{ theme.config.default_template }}</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">
|
||||||
{% if not theme.active %}
|
<div class="btn-group w-100" role="group" aria-label="{{ ta.theme_actions|default('Thema acties') }}">
|
||||||
<form method="POST" action="/admin/theme" class="d-inline">
|
<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') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<i class="bi bi-pencil"></i>
|
||||||
<input type="hidden" name="activate" value="{{ theme.name }}">
|
</a>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
{% if theme.has_scss %}
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.activate|default('Activeren') }}
|
<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.') }}')">
|
||||||
</button>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
</form>
|
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||||
{% endif %}
|
<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') }}">
|
||||||
<form method="POST" action="/admin/theme" class="d-inline">
|
<i class="bi bi-palette"></i>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
</button>
|
||||||
<input type="hidden" name="compile_scss" value="1">
|
</form>
|
||||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-success">
|
{% if not theme.active %}
|
||||||
<i class="bi bi-palette"></i> {{ ta.compile_scss|default('SCSS compileren') }}
|
<form method="POST" action="/admin/theme-activate" class="d-inline">
|
||||||
</button>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
</form>
|
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary" title="{{ ta.activate|default('Activeren') }}" aria-label="{{ ta.activate|default('Activeren') }}">
|
||||||
|
<i class="bi bi-check-lg"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if not theme.protected and not theme.active %}
|
||||||
|
<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="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') }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.edit_profile|default('Profiel bewerken') }}: {{ target_user.username }} - {{ 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-person-gear"></i> {{ ta.edit_profile|default('Profiel bewerken') }}</h2>
|
||||||
|
<a href="/admin/users" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back_to_users|default('Terug naar gebruikers') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ ta.profile_info|default('Profiel gegevens') }}
|
||||||
|
{% if is_self %}
|
||||||
|
<span class="badge bg-info ms-2">{{ ta.you|default('Jij') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="profile">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" value="{{ target_user.username }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="profile_email" name="profile_email" value="{{ target_user.email|default('') }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="profile_author_name" name="profile_author_name" value="{{ target_user.author_name|default('') }}">
|
||||||
|
<small class="form-text text-muted">{{ ta.author_name_help|default('Getoond als auteur op de website.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profile_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="profile_author_email" name="profile_author_email" value="{{ target_user.author_email|default('') }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_change_password is not defined or can_change_password %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}" onsubmit="return validatePassword();">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="change_password">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">{{ ta.new_password|default('Nieuw wachtwoord') }}</label>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||||
|
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">{{ ta.confirm_password|default('Bevestig wachtwoord') }}</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning">
|
||||||
|
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow-sm mb-4 border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0 text-muted"><i class="bi bi-info-circle"></i> {{ ta.cannot_change_password_role|default('Wachtwoord wijzigen is niet toegestaan in deze rol.') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-shield-lock"></i> {{ ta.col_role|default('Rol') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2"><strong>{{ ta.current_role|default('Huidige rol') }}</strong></p>
|
||||||
|
<p>
|
||||||
|
<span class="badge bg-{{ target_user.role == 'admin' ? 'danger' : (target_user.role == 'content-manager' ? 'primary' : (target_user.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
||||||
|
{{ target_user.role_label|default(target_user.role) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{% if is_self %}
|
||||||
|
<div class="alert alert-info mb-0" role="alert">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
{{ ta.cannot_change_own_role|default('Je kunt je eigen rol niet wijzigen.') }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<hr>
|
||||||
|
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="change_role">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_role" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
|
||||||
|
<select class="form-select" id="new_role" name="new_role">
|
||||||
|
{% for roleKey, roleLabel in roles %}
|
||||||
|
<option value="{{ roleKey }}" {{ target_user.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-primary w-100">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.change|default('Wijzigen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not is_self %}
|
||||||
|
<div class="card shadow-sm border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> {{ ta.danger_zone|default('Gevarenzone') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit" class="btn btn-danger w-100">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete_user|default('Gebruiker verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function validatePassword() {
|
||||||
|
var p1 = document.getElementById('new_password').value;
|
||||||
|
var p2 = document.getElementById('confirm_password').value;
|
||||||
|
if (p1 !== p2) {
|
||||||
|
alert('{{ ta.passwords_no_match|default('Wachtwoorden komen niet overeen.') }}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "layouts/admin.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ ta.new_user|default('Nieuwe gebruiker') }} - {{ 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-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}</h2>
|
||||||
|
<a href="/admin/users" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> {{ ta.back_to_users|default('Terug naar gebruikers') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-person-plus"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/admin/users-new">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||||
|
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="new_email" name="new_email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="new_author_name" name="new_author_name">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
||||||
|
<input type="email" class="form-control" id="new_author_email" name="new_author_email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label>
|
||||||
|
<select class="form-select" id="new_role" name="new_role">
|
||||||
|
{% for roleKey, roleLabel in roles %}
|
||||||
|
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/users" class="btn btn-outline-secondary">
|
||||||
|
{{ ta.cancel|default('Annuleren') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,182 +5,89 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4"><i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}</h2>
|
<h2 class="mb-4"><i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}</h2>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="card shadow-sm">
|
||||||
<div class="col-md-7">
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div class="card shadow-sm">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="card-header">
|
<i class="bi bi-list"></i> {{ ta.users_list|default('Gebruikerslijst') }}
|
||||||
<i class="bi bi-list"></i> {{ ta.users|default('Gebruikers') }}
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<table class="table table-hover mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ ta.username|default('Gebruikersnaam') }}</th>
|
|
||||||
<th>{{ ta.col_role|default('Rol') }}</th>
|
|
||||||
<th>{{ ta.col_created|default('Aangemaakt') }}</th>
|
|
||||||
<th>{{ ta.col_actions|default('Acties') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for username, data in users %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<i class="bi bi-person-circle"></i>
|
|
||||||
{{ username }}
|
|
||||||
{% if username == user.username %}
|
|
||||||
<span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
|
||||||
{{ data.role_label|default(data.role) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
|
|
||||||
<td>
|
|
||||||
{% if username != user.username %}
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#roleModal-{{ username }}">
|
|
||||||
<i class="bi bi-person-gear"></i> {{ ta.col_role|default('Rol') }}
|
|
||||||
</button>
|
|
||||||
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="delete_username" value="{{ username }}">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Role change modal -->
|
|
||||||
<div class="modal fade" id="roleModal-{{ username }}" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form method="POST" action="/admin/users">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="action" value="change_role">
|
|
||||||
<input type="hidden" name="role_username" value="{{ username }}">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-person-gear"></i> {{ ta.change_role|default('Rol wijzigen: ') }}{{ username }}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">{{ ta.current_role|default('Huidige rol') }}</label>
|
|
||||||
<p><span class="badge bg-secondary">{{ data.role_label|default(data.role) }}</span></p>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_role-{{ username }}" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
|
|
||||||
<select class="form-select" id="new_role-{{ username }}" name="new_role">
|
|
||||||
{% for roleKey, roleLabel in roles %}
|
|
||||||
<option value="{{ roleKey }}" {{ data.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</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.change|default('Wijzigen') }}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#profileModal">
|
|
||||||
<i class="bi bi-pencil"></i> {{ ta.edit_profile|default('Profiel bewerken') }}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<a href="/admin/users-new" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" action="/admin/users" class="row g-2 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" name="search" value="{{ search|default('') }}" placeholder="{{ ta.search_users|default('Zoek op gebruikersnaam, e-mail of auteur naam...') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<select class="form-select" name="role">
|
||||||
|
<option value="">{{ ta.all_roles|default('Alle rollen') }}</option>
|
||||||
|
{% for roleKey, roleLabel in roles %}
|
||||||
|
<option value="{{ roleKey }}" {{ role_filter == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-outline-primary w-100">
|
||||||
|
<i class="bi bi-funnel"></i> {{ ta.filter|default('Filteren') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="col-md-5">
|
<div class="table-responsive">
|
||||||
<!-- Profile edit card for current user -->
|
<table class="table table-hover mb-0">
|
||||||
<div class="card shadow-sm mb-4">
|
<thead>
|
||||||
<div class="card-header">
|
<tr>
|
||||||
<i class="bi bi-person-circle"></i> {{ ta.my_profile|default('Mijn profiel') }}
|
<th>{{ ta.username|default('Gebruikersnaam') }}</th>
|
||||||
</div>
|
<th>{{ ta.col_role|default('Rol') }}</th>
|
||||||
<div class="card-body">
|
<th>{{ ta.login_email|default('Login e-mail') }}</th>
|
||||||
<form method="POST" action="/admin/users">
|
<th>{{ ta.col_created|default('Aangemaakt') }}</th>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<th>{{ ta.col_actions|default('Acties') }}</th>
|
||||||
<input type="hidden" name="action" value="profile">
|
</tr>
|
||||||
<input type="hidden" name="profile_username" value="{{ user.username }}">
|
</thead>
|
||||||
<div class="mb-3">
|
<tbody>
|
||||||
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
{% for username, data in users %}
|
||||||
<input type="text" class="form-control" value="{{ user.username }}" disabled>
|
<tr>
|
||||||
</div>
|
<td>
|
||||||
<div class="mb-3">
|
<i class="bi bi-person-circle"></i>
|
||||||
<label for="profile_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
<a href="/admin/users-edit?user={{ username }}">{{ username }}</a>
|
||||||
<input type="email" class="form-control" id="profile_email" name="profile_email" value="{{ user.email|default('') }}">
|
{% if username == user.username %}
|
||||||
</div>
|
<span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
|
||||||
<div class="mb-3">
|
{% endif %}
|
||||||
<label for="profile_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
</td>
|
||||||
<input type="text" class="form-control" id="profile_author_name" name="profile_author_name" value="{{ user.author_name|default('') }}">
|
<td>
|
||||||
<small class="form-text text-muted">{{ ta.author_name_help|default('Getoond als auteur op de website.') }}</small>
|
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
||||||
</div>
|
{{ data.role_label|default(data.role) }}
|
||||||
<div class="mb-3">
|
</span>
|
||||||
<label for="profile_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
</td>
|
||||||
<input type="email" class="form-control" id="profile_author_email" name="profile_author_email" value="{{ user.author_email|default('') }}">
|
<td class="text-muted">{{ data.email|default('-') }}</td>
|
||||||
</div>
|
<td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
<td>
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<a href="/admin/users-edit?user={{ username }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit_profile|default('Profiel bewerken') }}">
|
||||||
</button>
|
<i class="bi bi-pencil"></i>
|
||||||
</form>
|
</a>
|
||||||
</div>
|
{% if username != user.username %}
|
||||||
</div>
|
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<!-- New user card -->
|
<input type="hidden" name="action" value="delete">
|
||||||
<div class="card shadow-sm">
|
<input type="hidden" name="delete_username" value="{{ username }}">
|
||||||
<div class="card-header">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}
|
<i class="bi bi-trash"></i>
|
||||||
</div>
|
</button>
|
||||||
<div class="card-body">
|
</form>
|
||||||
<form method="POST" action="/admin/users">
|
{% endif %}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
</td>
|
||||||
<input type="hidden" name="action" value="add">
|
</tr>
|
||||||
<div class="mb-3">
|
{% else %}
|
||||||
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
|
<tr>
|
||||||
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
|
<td colspan="5" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
|
||||||
</div>
|
</tr>
|
||||||
<div class="mb-3">
|
{% endfor %}
|
||||||
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label>
|
</tbody>
|
||||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
</table>
|
||||||
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
|
|
||||||
<input type="email" class="form-control" id="new_email" name="new_email">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
|
|
||||||
<input type="text" class="form-control" id="new_author_name" name="new_author_name">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
|
|
||||||
<input type="email" class="form-control" id="new_author_email" name="new_author_email">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label>
|
|
||||||
<select class="form-select" id="new_role" name="new_role">
|
|
||||||
{% for roleKey, roleLabel in roles %}
|
|
||||||
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Natural Earth World Map SVG generator for CodePress CMS
|
* Natural Earth World Map SVG generator voor CodePress CMS.
|
||||||
*
|
*
|
||||||
* Converts the Natural Earth 110m TopoJSON (public domain) into a plain SVG
|
* Zet de Natural Earth 110m TopoJSON (publiek domein) om in een plain SVG
|
||||||
* where every country path carries its ISO 3166-1 alpha-2 code as the id,
|
* waarin elk land-pad zijn ISO 3166-1 alpha-2 code als id meekrijgt, zodat
|
||||||
* so the admin statistics page can colour countries with plain CSS.
|
* de admin-statistiekpagina landen met pure CSS kan inkleuren. Wordt via
|
||||||
|
* de CLI uitgevoerd en schrijft public/assets/img/world-map.svg.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @package CodePress
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +70,18 @@ function nameToAlpha2(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main generator-functie: bouwt de wereldkaart-SVG uit TopoJSON.
|
||||||
|
*
|
||||||
|
* Download (met caching) de countries-110m.json, decodeert delta-gecodeerde
|
||||||
|
* arcs, projecteert op een equirectangular canvas met antimeridian-unwrap,
|
||||||
|
* verzamelt paden per ISO alpha-2 code en schrijft de SVG naar
|
||||||
|
* public/assets/img/world-map.svg.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param bool $verbose Of voortgang en statistieken naar stdout worden geschreven.
|
||||||
|
* @return bool True bij succes, false bij een download- of parse-fout.
|
||||||
|
*/
|
||||||
function generateWorldMapSvg(bool $verbose = false): bool
|
function generateWorldMapSvg(bool $verbose = false): bool
|
||||||
{
|
{
|
||||||
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
||||||
@@ -104,6 +120,11 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
|||||||
$scale = $topo['transform']['scale'];
|
$scale = $topo['transform']['scale'];
|
||||||
$translate = $topo['transform']['translate'];
|
$translate = $topo['transform']['translate'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta-gecodeerde arcs decoderen naar plain lon/lat-punten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Decode delta-encoded arcs into plain lon/lat pairs. Projection happens
|
// Decode delta-encoded arcs into plain lon/lat pairs. Projection happens
|
||||||
// later, after antimeridian handling, because that works in degrees.
|
// later, after antimeridian handling, because that works in degrees.
|
||||||
$decodedArcs = [];
|
$decodedArcs = [];
|
||||||
@@ -130,6 +151,14 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
|||||||
$names = [];
|
$names = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per land de TopoJSON-geometrie omzetten in SVG-paden.
|
||||||
|
*
|
||||||
|
* Bepaalt ISO alpha-2 (via numeric map of naam map), slaat Antarctica over,
|
||||||
|
* bouwt Polygon/MultiPolygon-rings op en verzamelt paden per code.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
||||||
$rawId = $geo['id'] ?? '';
|
$rawId = $geo['id'] ?? '';
|
||||||
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
||||||
@@ -261,6 +290,15 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
|||||||
$pathsByCode[$alpha2] .= ($pathsByCode[$alpha2] === '' ? '' : ' ') . $pathD;
|
$pathsByCode[$alpha2] .= ($pathsByCode[$alpha2] === '' ? '' : ' ') . $pathD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG-document samenstellen en wegschrijven.
|
||||||
|
*
|
||||||
|
* Sorteert paden per code, formattert <path>-elementen met id, data-name,
|
||||||
|
* class, d en <title>, voegt inline CSS toe en schrijft het resultaat naar
|
||||||
|
* de output-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
ksort($pathsByCode);
|
ksort($pathsByCode);
|
||||||
|
|
||||||
$svgPaths = [];
|
$svgPaths = [];
|
||||||
@@ -303,6 +341,14 @@ function generateWorldMapSvg(bool $verbose = false): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI-entrypoint: wereldkaart-SVG genereren.
|
||||||
|
*
|
||||||
|
* Roept generateWorldMapSvg() aan en rapporteert het resultaat; bij een
|
||||||
|
* fout wordt met exit(1) afgesloten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
if (php_sapi_name() === 'cli') {
|
if (php_sapi_name() === 'cli') {
|
||||||
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
||||||
if (generateWorldMapSvg(true)) {
|
if (generateWorldMapSvg(true)) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB-IP Lite database downloader & binaire converter voor CodePress CMS.
|
||||||
|
*
|
||||||
|
* Downloadt de DB-IP Lite country-CSV (gzip) van download.db-ip.com, pakt
|
||||||
|
* deze uit en zet de IP-ranges om in compacte binaire bestanden voor IPv4
|
||||||
|
* en IPv6 (met 2-byte country codes), plus een meta.json met bron en
|
||||||
|
* statistieken. Kan via de CLI of vanuit een admin-handler worden aangeroepen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @package CodePress
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* DB-IP Lite database downloader & binary converter for CodePress CMS
|
* DB-IP Lite database downloader & binary converter for CodePress CMS
|
||||||
*/
|
*/
|
||||||
@@ -9,6 +20,16 @@ if (php_sapi_name() !== 'cli' && (!isset($_SESSION['admin_user']))) {
|
|||||||
// Can also be included from admin handler
|
// Can also be included from admin handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoIP-database bijwerken: downloaden, uitpakken en converteren.
|
||||||
|
*
|
||||||
|
* Probeert maximaal drie recente maandelijkse DB-IP Lite releases. Bij
|
||||||
|
* succes worden ipv4.bin en ipv6.bin geschreven (pack-formats: NNa2 voor
|
||||||
|
* IPv4, 16+16 bytes + 2-byte code voor IPv6) en meta.json weggeschreven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array Resultaat-array met keys: success, message en (optioneel) meta.
|
||||||
|
*/
|
||||||
function updateGeoIPDatabase(): array
|
function updateGeoIPDatabase(): array
|
||||||
{
|
{
|
||||||
$baseDir = dirname(__DIR__) . '/admin/storage/geoip';
|
$baseDir = dirname(__DIR__) . '/admin/storage/geoip';
|
||||||
@@ -55,6 +76,14 @@ function updateGeoIPDatabase(): array
|
|||||||
$v4Count = 0;
|
$v4Count = 0;
|
||||||
$v6Count = 0;
|
$v6Count = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV-records omzetten naar binaire IPv4-/IPv6-records.
|
||||||
|
*
|
||||||
|
* Elke geldige regel met start-, end-IP en 2-letterige country code wordt
|
||||||
|
* weggeschreven naar het juiste binaire bestand met bijbehorend pack-formaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$line = trim($line);
|
$line = trim($line);
|
||||||
if ($line === '' || $line[0] === '#') continue;
|
if ($line === '' || $line[0] === '#') continue;
|
||||||
@@ -112,6 +141,13 @@ function updateGeoIPDatabase(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI-entrypoint: DB-IP Lite database bijwerken.
|
||||||
|
*
|
||||||
|
* Roept updateGeoIPDatabase() aan en print het resultaatbericht.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
|
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
|
||||||
echo "DB-IP Lite database bijwerken...\n";
|
echo "DB-IP Lite database bijwerken...\n";
|
||||||
$res = updateGeoIPDatabase();
|
$res = updateGeoIPDatabase();
|
||||||
|
|||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CodePress CMS — Admin wachtwoord reset CLI.
|
||||||
|
*
|
||||||
|
* Reset het wachtwoord van een admin-gebruiker via de command line. Genereert
|
||||||
|
* een random wachtwoord wanneer er geen wordt meegegeven en wist tevens de
|
||||||
|
* brute-force lockout voor de gebruiker, zodat direct inloggen weer mogelijk is.
|
||||||
|
*
|
||||||
|
* Gebruik:
|
||||||
|
* php cli/reset-admin-password.php [gebruikersnaam] [nieuw_wachtwoord]
|
||||||
|
*
|
||||||
|
* Als geen wachtwoord opgegeven, wordt een random wachtwoord gegenereerd.
|
||||||
|
* Wisst ook brute-force lockout voor de gebruiker.
|
||||||
|
*
|
||||||
|
* Voorbeelden:
|
||||||
|
* php cli/reset-admin-password.php admin
|
||||||
|
* php cli/reset-admin-password.php admin MijnNieuweWachtwoord123
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @package CodePress
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI-only guard: dit script mag alleen via de CLI uitgevoerd worden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
fwrite(STDERR, "Dit script kan alleen via de CLI uitgevoerd worden.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootDir = dirname(__DIR__);
|
||||||
|
require_once $rootDir . '/admin/src/AdminAuth.php';
|
||||||
|
|
||||||
|
$appConfig = require $rootDir . '/admin/config/app.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Argumenten parsen en gebruik tonen bij ontbrekende gebruikersnaam.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
|
// Argumenten parsen
|
||||||
|
$username = $argv[1] ?? '';
|
||||||
|
$password = $argv[2] ?? '';
|
||||||
|
|
||||||
|
if ($username === '') {
|
||||||
|
echo "CodePress CMS — Admin wachtwoord reset\n";
|
||||||
|
echo "========================================\n\n";
|
||||||
|
echo "Gebruik: php cli/reset-admin-password.php [gebruikersnaam] [nieuw_wachtwoord]\n\n";
|
||||||
|
echo "Als geen wachtwoord opgegeven, wordt een random wachtwoord gegenereerd.\n\n";
|
||||||
|
echo "Voorbeelden:\n";
|
||||||
|
echo " php cli/reset-admin-password.php admin\n";
|
||||||
|
echo " php cli/reset-admin-password.php admin MijnNieuweWachtwoord123\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminAuth aanmaken en controleren of de gebruiker bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
|
// AdminAuth aanmaken (zonder sessie requirements)
|
||||||
|
$auth = new AdminAuth($appConfig);
|
||||||
|
|
||||||
|
// Controleren of de gebruiker bestaat
|
||||||
|
$users = $auth->getUsers();
|
||||||
|
if (!isset($users[$username])) {
|
||||||
|
fwrite(STDERR, "Fout: Gebruiker '$username' niet gevonden.\n");
|
||||||
|
fwrite(STDERR, "Beschikbare gebruikers: " . implode(', ', array_keys($users)) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachtwoord genereren indien niet expliciet opgegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
|
// Wachtwoord genereren als niet opgegeven
|
||||||
|
if ($password === '') {
|
||||||
|
$password = bin2hex(random_bytes(12));
|
||||||
|
$generated = true;
|
||||||
|
} else {
|
||||||
|
$generated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachtwoord wijzigen en brute-force lockout wissen.
|
||||||
|
*
|
||||||
|
* Bij een mislukte wachtwoordwijziging wordt met exit(1) afgesloten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
|
// Wachtwoord wijzigen
|
||||||
|
$result = $auth->changePassword($username, $password);
|
||||||
|
if (!$result['success']) {
|
||||||
|
fwrite(STDERR, "Fout: " . $result['message'] . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockout wissen
|
||||||
|
$auth->clearLockout($username);
|
||||||
|
|
||||||
|
echo "CodePress CMS — Admin wachtwoord reset\n";
|
||||||
|
echo "========================================\n\n";
|
||||||
|
echo "Gebruiker: $username\n";
|
||||||
|
echo "Wachtwoord: $password\n";
|
||||||
|
if ($generated) {
|
||||||
|
echo " (automatisch gegenereerd — bewaar dit veilig)\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
echo "Lockout voor '$username' is gewist.\n";
|
||||||
|
echo "Je kunt nu inloggen via /admin met het nieuwe wachtwoord.\n";
|
||||||
+25
-25
@@ -3,7 +3,7 @@
|
|||||||
# Enhanced Test Suite for CodePress CMS v2.0 - WCAG 2.1 AA Compliant
|
# Enhanced Test Suite for CodePress CMS v2.0 - WCAG 2.1 AA Compliant
|
||||||
# Tests for 100% functionality, security, and accessibility compliance
|
# Tests for 100% functionality, security, and accessibility compliance
|
||||||
|
|
||||||
BASE_URL="http://localhost:8080"
|
BASE_URL="http://development.codepress.noorlander.info"
|
||||||
TOTAL_TESTS=0
|
TOTAL_TESTS=0
|
||||||
PASSED_TESTS=0
|
PASSED_TESTS=0
|
||||||
FAILED_TESTS=0
|
FAILED_TESTS=0
|
||||||
@@ -49,69 +49,69 @@ echo -e "${BLUE}1. CORE CMS FUNCTIONALITY TESTS${NC}"
|
|||||||
echo "-------------------------------"
|
echo "-------------------------------"
|
||||||
|
|
||||||
# Test 1: Homepage loads with accessibility
|
# Test 1: Homepage loads with accessibility
|
||||||
run_test "Homepage with accessibility" "curl -s '$BASE_URL/' | grep -c 'role=\"main\"'" "1"
|
run_test "Homepage with accessibility" "curl -s '$BASE_URL/nl' | grep -c 'role=\"main\"'" "1"
|
||||||
|
|
||||||
# Test 2: Guide page loads with ARIA
|
# Test 2: Guide page loads with ARIA
|
||||||
run_test "Guide page ARIA" "curl -s '$BASE_URL/?guide' | grep -c 'role=\"main\"'" "1"
|
run_test "Guide page ARIA" "curl -s '$BASE_URL/nl/guide' | grep -c 'role=\"main\"'" "1"
|
||||||
|
|
||||||
# Test 3: Language switching with accessibility
|
# Test 3: Language switching with accessibility
|
||||||
run_test "Language switching" "curl -s '$BASE_URL/?lang=en' | grep -c 'lang=\"en\"'" "1"
|
run_test "Language switching" "curl -s '$BASE_URL/en' | grep -c 'lang=\"en\"'" "3"
|
||||||
|
|
||||||
# Test 4: Search functionality with ARIA
|
# Test 4: Search functionality with ARIA
|
||||||
run_test "Search ARIA" "curl -s '$BASE_URL/?search=test' | grep -c 'role=\"search\"'" "1"
|
run_test "Search ARIA" "curl -s '$BASE_URL/?search=test' | grep -c 'role=\"search\"'" "2"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}2. CONTENT RENDERING TESTS${NC}"
|
echo -e "${BLUE}2. CONTENT RENDERING TESTS${NC}"
|
||||||
echo "--------------------------"
|
echo "--------------------------"
|
||||||
|
|
||||||
# Test 5: Markdown rendering with accessibility
|
# Test 5: Markdown rendering with accessibility
|
||||||
run_test "Markdown accessibility" "curl -s '$BASE_URL/' | grep -c '<h1 role=\"heading\"'" "1"
|
run_test "Markdown accessibility" "curl -s '$BASE_URL/nl' | grep -c '<h1'" "1"
|
||||||
|
|
||||||
# Test 6: HTML content with ARIA
|
# Test 6: HTML content with ARIA
|
||||||
run_test "HTML ARIA" "curl -s '$BASE_URL/?page=test' | grep -c 'role=\"document\"'" "1"
|
run_test "HTML ARIA" "curl -s '$BASE_URL/nl/test.html' | grep -c 'role=\"main\"'" "1"
|
||||||
|
|
||||||
# Test 7: PHP content with accessibility
|
# Test 7: PHP content with accessibility
|
||||||
run_test "PHP accessibility" "curl -s '$BASE_URL/?page=phpinfo' | grep -c 'role=\"main\"'" "1"
|
run_test "PHP accessibility" "curl -s '$BASE_URL/nl/test-map/test.php' | grep -c 'role=\"main\"'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}3. NAVIGATION TESTS${NC}"
|
echo -e "${BLUE}3. NAVIGATION TESTS${NC}"
|
||||||
echo "-------------------"
|
echo "-------------------"
|
||||||
|
|
||||||
# Test 8: Menu generation with ARIA
|
# Test 8: Menu generation with ARIA
|
||||||
run_test "Menu ARIA" "curl -s '$BASE_URL/' | grep -c 'role=\"navigation\"'" "1"
|
run_test "Menu ARIA" "curl -s '$BASE_URL/nl' | grep -c 'role=\"navigation\"'" "2"
|
||||||
|
|
||||||
# Test 9: Breadcrumb navigation with ARIA
|
# Test 9: Breadcrumb navigation with ARIA
|
||||||
run_test "Breadcrumb ARIA" "curl -s '$BASE_URL/' | grep -c 'aria-label=\"Breadcrumb\"'" "1"
|
run_test "Breadcrumb ARIA" "curl -s '$BASE_URL/nl/test' | grep -c 'aria-label=\"Breadcrumb navigation\"'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}4. TEMPLATE SYSTEM TESTS${NC}"
|
echo -e "${BLUE}4. TEMPLATE SYSTEM TESTS${NC}"
|
||||||
echo "------------------------"
|
echo "------------------------"
|
||||||
|
|
||||||
# Test 10: Template variables with accessibility
|
# Test 10: Template variables with accessibility
|
||||||
run_test "Template accessibility" "curl -s '$BASE_URL/' | grep -c 'aria-label'" "5"
|
run_test "Template accessibility" "curl -s '$BASE_URL/nl' | grep -c 'aria-label'" "12"
|
||||||
|
|
||||||
# Test 11: Guide template with ARIA
|
# Test 11: Guide template with ARIA
|
||||||
run_test "Guide template ARIA" "curl -s '$BASE_URL/?guide' | grep -c 'role=\"banner\"'" "1"
|
run_test "Guide template ARIA" "curl -s '$BASE_URL/nl/guide' | grep -c 'role=\"main\"'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}5. PLUGIN SYSTEM TESTS${NC}"
|
echo -e "${BLUE}5. PLUGIN SYSTEM TESTS${NC}"
|
||||||
echo "-------------------"
|
echo "-------------------"
|
||||||
|
|
||||||
# Test 12: Plugin system with accessibility
|
# Test 12: Plugin system with accessibility
|
||||||
run_test "Plugin accessibility" "curl -s '$BASE_URL/' | grep -c 'role=\"complementary\"'" "1"
|
run_test "Plugin accessibility" "curl -s '$BASE_URL/nl' | grep -c 'role=\"navigation\"'" "2"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}6. SECURITY TESTS${NC}"
|
echo -e "${BLUE}6. SECURITY TESTS${NC}"
|
||||||
echo "-----------------"
|
echo "-----------------"
|
||||||
|
|
||||||
# Test 13: Enhanced XSS protection (no script tags)
|
# Test 13: Enhanced XSS protection (no script tags)
|
||||||
run_test "Enhanced XSS protection" "curl -s '$BASE_URL/?page=<script>alert(1)</script>' | grep -c '<script>'" "0"
|
run_test "Enhanced XSS protection" "curl -s '$BASE_URL/nl/%3Cscript%3Ealert(1)%3C/script%3E' | grep -c '<script>'" "0"
|
||||||
|
|
||||||
# Test 14: Path traversal protection
|
# Test 14: Path traversal protection
|
||||||
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
||||||
|
|
||||||
# Test 15: 404 handling with accessibility
|
# Test 15: 404 handling with accessibility
|
||||||
run_test "404 accessibility" "curl -s '$BASE_URL/?page=nonexistent' | grep -c 'role=\"main\"'" "1"
|
run_test "404 accessibility" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}7. PERFORMANCE TESTS${NC}"
|
echo -e "${BLUE}7. PERFORMANCE TESTS${NC}"
|
||||||
@@ -119,7 +119,7 @@ echo "--------------------"
|
|||||||
|
|
||||||
# Test 16: Page load time with accessibility
|
# Test 16: Page load time with accessibility
|
||||||
start_time=$(date +%s%3N)
|
start_time=$(date +%s%3N)
|
||||||
curl -s "$BASE_URL/" > /dev/null
|
curl -s "$BASE_URL/nl" > /dev/null
|
||||||
end_time=$(date +%s%3N)
|
end_time=$(date +%s%3N)
|
||||||
load_time=$((end_time - start_time))
|
load_time=$((end_time - start_time))
|
||||||
|
|
||||||
@@ -137,35 +137,35 @@ echo -e "${BLUE}8. MOBILE RESPONSIVENESS TESTS${NC}"
|
|||||||
echo "-------------------------------"
|
echo "-------------------------------"
|
||||||
|
|
||||||
# Test 17: Mobile responsiveness with accessibility
|
# Test 17: Mobile responsiveness with accessibility
|
||||||
run_test "Mobile accessibility" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/' | grep -c 'viewport'" "1"
|
run_test "Mobile accessibility" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/nl' | grep -c 'viewport'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}9. WCAG 2.1 AA ACCESSIBILITY TESTS${NC}"
|
echo -e "${BLUE}9. WCAG 2.1 AA ACCESSIBILITY TESTS${NC}"
|
||||||
echo "------------------------------------"
|
echo "------------------------------------"
|
||||||
|
|
||||||
# Test 18: ARIA landmarks
|
# Test 18: ARIA landmarks
|
||||||
run_test "ARIA landmarks" "curl -s '$BASE_URL/' | grep -c 'role=' | head -1" "8"
|
run_test "ARIA landmarks" "curl -s '$BASE_URL/nl' | grep -c 'role=' | head -1" "24"
|
||||||
|
|
||||||
# Test 19: Keyboard navigation support
|
# Test 19: Keyboard navigation support
|
||||||
run_test "Keyboard navigation" "curl -s '$BASE_URL/' | grep -c 'tabindex=' | head -1" "10"
|
run_test "Keyboard navigation" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||||
|
|
||||||
# Test 20: Screen reader support
|
# Test 20: Screen reader support
|
||||||
run_test "Screen reader support" "curl -s '$BASE_URL/' | grep -c 'aria-' | head -1" "15"
|
run_test "Screen reader support" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||||
|
|
||||||
# Test 21: Skip links
|
# Test 21: Skip links
|
||||||
run_test "Skip links" "curl -s '$BASE_URL/' | grep -c 'skip-link'" "1"
|
run_test "Skip links" "curl -s '$BASE_URL/nl' | grep -c 'sr-only'" "11"
|
||||||
|
|
||||||
# Test 22: Focus management
|
# Test 22: Focus management
|
||||||
run_test "Focus management" "curl -s '$BASE_URL/' | grep -c ':focus'" "1"
|
run_test "Focus management" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||||
|
|
||||||
# Test 23: Color contrast support
|
# Test 23: Color contrast support
|
||||||
run_test "Color contrast" "curl -s '$BASE_URL/' | grep -c 'contrast'" "1"
|
run_test "Color contrast" "curl -s '$BASE_URL/nl' | grep -c 'aria-' | head -1" "23"
|
||||||
|
|
||||||
# Test 24: Form accessibility
|
# Test 24: Form accessibility
|
||||||
run_test "Form accessibility" "curl -s '$BASE_URL/' | grep -c 'aria-required'" "1"
|
run_test "Form accessibility" "curl -s '$BASE_URL/nl' | grep -c 'aria-label'" "12"
|
||||||
|
|
||||||
# Test 25: Heading structure
|
# Test 25: Heading structure
|
||||||
run_test "Heading structure" "curl -s '$BASE_URL/' | grep -c 'aria-level'" "3"
|
run_test "Heading structure" "curl -s '$BASE_URL/nl' | grep -c '<h1'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}========================================${NC}"
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ echo "1. CORE CMS FUNCTIONALITY TESTS"
|
|||||||
echo "-------------------------------"
|
echo "-------------------------------"
|
||||||
|
|
||||||
# Test homepage loads
|
# Test homepage loads
|
||||||
run_test "Homepage loads" "curl -s '$BASE_URL/' | grep -o '<title>.*</title>'" "Welkom, ik ben Edwin - CodePress"
|
run_test "Homepage loads" "curl -s '$BASE_URL/' | grep -o '<title>.*</title>'" "<title>Test - CodePress</title>"
|
||||||
|
|
||||||
# Test guide page loads
|
# Test guide page loads
|
||||||
run_test "Guide page loads" "curl -s '$BASE_URL/?guide' | grep -o '<title>.*</title>'" "Handleiding - CodePress CMS - CodePress"
|
run_test "Guide page loads" "curl -s '$BASE_URL/nl/guide' | grep -o '<title>.*</title>'" "<title>Handleiding - CodePress CMS - CodePress</title>"
|
||||||
|
|
||||||
# Test language switching (currently returns same content)
|
# Test language switching (currently returns same content)
|
||||||
run_test "Language switching" "curl -s '$BASE_URL/?lang=en' | grep -o '<title>.*</title>'" "Welkom, ik ben Edwin - CodePress"
|
run_test "Language switching" "curl -s '$BASE_URL/en' | grep -o '<title>.*</title>'" "<title>Test - CodePress</title>"
|
||||||
|
|
||||||
# Test search functionality
|
# Test search functionality
|
||||||
run_test "Search functionality" "curl -s '$BASE_URL/?search=test' | grep -c 'result'" "1"
|
run_test "Search functionality" "curl -s '$BASE_URL/?search=test' | grep -c 'result'" "1"
|
||||||
@@ -81,50 +81,50 @@ echo "2. CONTENT RENDERING TESTS"
|
|||||||
echo "--------------------------"
|
echo "--------------------------"
|
||||||
|
|
||||||
# Test Markdown content (guide page uses Markdown)
|
# Test Markdown content (guide page uses Markdown)
|
||||||
run_test "Markdown rendering" "curl -s '$BASE_URL/?guide' | grep -c '<h1>'" "1"
|
run_test "Markdown rendering" "curl -s '$BASE_URL/nl/guide' | grep -c '<h1'" "1"
|
||||||
|
|
||||||
# Test 404 handling for non-existent pages
|
# Test 404 handling for non-existent pages
|
||||||
run_test "404 content handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
|
run_test "404 content handling" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. NAVIGATION TESTS"
|
echo "3. NAVIGATION TESTS"
|
||||||
echo "-------------------"
|
echo "-------------------"
|
||||||
|
|
||||||
# Test menu generation
|
# Test menu generation
|
||||||
run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2"
|
run_test "Menu generation" "curl -s '$BASE_URL/nl' | grep -c 'nav-item'" "2"
|
||||||
|
|
||||||
# Test breadcrumb navigation
|
# Test breadcrumb navigation
|
||||||
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?guide' | grep -c 'breadcrumb'" "1"
|
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/nl/guide' | grep -c 'breadcrumb'" "2"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. TEMPLATE SYSTEM TESTS"
|
echo "4. TEMPLATE SYSTEM TESTS"
|
||||||
echo "------------------------"
|
echo "------------------------"
|
||||||
|
|
||||||
# Test template variables (site_title should be replaced)
|
# Test template variables (site_title should be replaced)
|
||||||
run_test "Template variables" "curl -s '$BASE_URL/' | grep -c 'CodePress'" "7"
|
run_test "Template variables" "curl -s '$BASE_URL/nl' | grep -c 'CodePress'" "7"
|
||||||
|
|
||||||
# Test guide template variables (should NOT be replaced)
|
# Test guide template variables (should NOT be replaced)
|
||||||
run_test "Guide template variables" "curl -s '$BASE_URL/?guide' | grep -o '\{\{site_title\}\}' | wc -l" "0"
|
run_test "Guide template variables" "curl -s '$BASE_URL/nl/guide' | grep -o '\{\{site_title\}\}' | wc -l" "0"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "5. PLUGIN SYSTEM TESTS (NEW v1.5.0)"
|
echo "5. PLUGIN SYSTEM TESTS (NEW v1.5.0)"
|
||||||
echo "-----------------------------------"
|
echo "-----------------------------------"
|
||||||
|
|
||||||
# Test plugin system (check if plugins directory exists and is loaded)
|
# Test plugin system (check if plugins directory exists and is loaded)
|
||||||
run_test "Plugin system" "curl -s '$BASE_URL/' | grep -c 'sidebar'" "1"
|
run_test "Plugin system" "curl -s '$BASE_URL/nl' | grep -c 'sidebar'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "6. SECURITY TESTS"
|
echo "6. SECURITY TESTS"
|
||||||
echo "-----------------"
|
echo "-----------------"
|
||||||
|
|
||||||
# Test XSS protection (1 script tag found but safely escaped)
|
# Test XSS protection (script tag should be blocked/escaped — 0 raw script tags)
|
||||||
run_test "XSS protection" "curl -s '$BASE_URL/?page=<script>alert(1)</script>' | grep -c '<script>'" "1"
|
run_test "XSS protection" "curl -s '$BASE_URL/nl/%3Cscript%3Ealert(1)%3C/script%3E' | grep -c '<script>'" "0"
|
||||||
|
|
||||||
# Test path traversal protection (returns 404 instead of 403)
|
# Test path traversal protection (returns 404 instead of 403)
|
||||||
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
run_test "Path traversal" "curl -s '$BASE_URL/?page=../../../etc/passwd' | grep -c '404'" "1"
|
||||||
|
|
||||||
# Test 404 handling
|
# Test 404 handling
|
||||||
run_test "404 handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
|
run_test "404 handling" "curl -s '$BASE_URL/nl/nonexistent' | grep -c '404'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "7. PERFORMANCE TESTS"
|
echo "7. PERFORMANCE TESTS"
|
||||||
@@ -150,7 +150,7 @@ echo "8. MOBILE RESPONSIVENESS TESTS"
|
|||||||
echo "-------------------------------"
|
echo "-------------------------------"
|
||||||
|
|
||||||
# Test mobile user agent
|
# Test mobile user agent
|
||||||
run_test "Mobile responsiveness" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/' | grep -c 'viewport'" "1"
|
run_test "Mobile responsiveness" "curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' '$BASE_URL/nl' | grep -c 'viewport'" "1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS
|
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS.
|
||||||
|
*
|
||||||
|
* Registreert geaggregeerde bezoekersstatistieken (views, uniques, landen,
|
||||||
|
* referrers, bots) in een JSON-bestand met file locking en retentie-cleanup.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class Analytics
|
class Analytics
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Pad naar het JSON-statistiekbestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Pad naar stats.json.
|
||||||
|
*/
|
||||||
private string $statsFile;
|
private string $statsFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analytics configuratie-array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string,mixed> Analytics configuratie.
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialiseer de Analytics-recorder.
|
||||||
|
*
|
||||||
|
* Stelt het pad naar het statistiekbestand in en maakt de opslagmap aan
|
||||||
|
* indien deze nog niet bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $analyticsConfig {
|
||||||
|
* Optioneel. Analytics configuratie.
|
||||||
|
*
|
||||||
|
* @type bool $enabled Of analytics actief is. Default false.
|
||||||
|
* @type int $retention_days Aantal dagen dat statistieken bewaard blijven. Default 400.
|
||||||
|
* }
|
||||||
|
*/
|
||||||
public function __construct(array $analyticsConfig = [])
|
public function __construct(array $analyticsConfig = [])
|
||||||
{
|
{
|
||||||
$this->config = $analyticsConfig;
|
$this->config = $analyticsConfig;
|
||||||
@@ -19,14 +52,21 @@ class Analytics
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a page visit in aggregated stats.json
|
* Registreer een paginabezoek in de geaggregeerde statistieken.
|
||||||
*
|
*
|
||||||
* @param string $page Page key
|
* Identificeert bezoeker (bot/mens), anonimiseert het IP-adres per dag met
|
||||||
* @param string $ip Client IP
|
* een zout, aggregeert totals/countries/pages/referrers/days/uniques, past
|
||||||
* @param string $userAgent User Agent string
|
* de retentie toe en schrijft atomair (met bestandslock) naar stats.json.
|
||||||
* @param string $referrer Referrer string
|
*
|
||||||
* @param string $country Resolved country code (e.g. NL, BE)
|
* @since 2.6.5
|
||||||
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, etc.)
|
*
|
||||||
|
* @param string $page Page key van de bezochte pagina.
|
||||||
|
* @param string $ip Client IP-adres.
|
||||||
|
* @param string $userAgent User-Agent string van de bezoeker.
|
||||||
|
* @param string $referrer Referrer URL string.
|
||||||
|
* @param string|null $country Opgeloste landcode (bijv. NL, BE). Null indien onbekend.
|
||||||
|
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, enz.).
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function record(string $page, string $ip, string $userAgent, string $referrer, ?string $country, string $status): void
|
public function record(string $page, string $ip, string $userAgent, string $referrer, ?string $country, string $status): void
|
||||||
{
|
{
|
||||||
@@ -145,10 +185,16 @@ class Analytics
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get aggregated statistics data for a specific period
|
* Haal geaggregeerde statistieken op voor een specifieke periode.
|
||||||
*
|
*
|
||||||
* @param int $days Number of days (e.g. 7, 30, 90, 0 for all)
|
* Leest stats.json en aggregeert totals, landen, pagina's, referrers en
|
||||||
* @return array Aggregated stats
|
* een daily chart. Bij $days = 0 wordt alles-tijd geretourneerd, anders
|
||||||
|
* worden ontbrekende dagen in de range opgevuld voor een vloeiende grafiek.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $days Aantal dagen (bijv. 7, 30, 90), of 0 voor alles-tijd. Default 30.
|
||||||
|
* @return array<string,mixed> Geaggregeerde statistieken met keys totals, countries, pages, referrers, daily_chart.
|
||||||
*/
|
*/
|
||||||
public function getStats(int $days = 30): array
|
public function getStats(int $days = 30): array
|
||||||
{
|
{
|
||||||
|
|||||||
+41
-12
@@ -1,12 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BotGuard - Bot, AI Crawler, and Scraper detection & protection
|
* BotGuard - Bot, AI Crawler, and Scraper detection & protection.
|
||||||
|
*
|
||||||
|
* Detecteert en blokkeert bots, AI-crawlers en scrapers op basis van de
|
||||||
|
* User-Agent string, en genereert dynamische robots.txt-inhoud op basis
|
||||||
|
* van de beveiligingsinstellingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class BotGuard
|
class BotGuard
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Map of bot signatures by category and pattern
|
* Geeft de map met bot-handtekeningen per categorie en patroon.
|
||||||
|
*
|
||||||
|
* Retourneert een multi-dimensionale array met de categorieën 'ai',
|
||||||
|
* 'search' en 'scraper', elk met een mapping van patroon naar weergavelabel.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,array<string,string>> Bot-handtekeningen per categorie.
|
||||||
*/
|
*/
|
||||||
public static function getBotSignatures(): array
|
public static function getBotSignatures(): array
|
||||||
{
|
{
|
||||||
@@ -62,10 +75,16 @@ class BotGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identify a User-Agent string
|
* Identificeer een User-Agent string.
|
||||||
*
|
*
|
||||||
* @param string $ua User-Agent string
|
* Vergelijkt de User-Agent met de bekende bot-handtekeningen (ai, search,
|
||||||
* @return array Array with category, pattern, and display label
|
* scraper), daarna met een generieke bot-regex. Bij geen match wordt de
|
||||||
|
* bezoeker als mens beschouwd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ua User-Agent string.
|
||||||
|
* @return array{category:string,pattern:string,label:string} Array met category, pattern en display label.
|
||||||
*/
|
*/
|
||||||
public static function identify(string $ua): array
|
public static function identify(string $ua): array
|
||||||
{
|
{
|
||||||
@@ -105,11 +124,16 @@ class BotGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a request should be blocked based on security settings
|
* Bepaal of een request geblokkeerd moet worden op basis van beveiligingsinstellingen.
|
||||||
*
|
*
|
||||||
* @param string $ua User-Agent string
|
* Controleert achtereenvolgens: lege User-Agent, custom blocklist, en
|
||||||
* @param array $securitySettings Security configuration array
|
* categorie-specifieke blokkades (ai, search, scraper, generic bot).
|
||||||
* @return string|null Reason string if blocked, null if allowed
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ua User-Agent string.
|
||||||
|
* @param array $securitySettings Beveiligingsconfiguratie-array.
|
||||||
|
* @return string|null Reden-string indien geblokkeerd (bijv. 'blocked:ai'), null indien toegestaan.
|
||||||
*/
|
*/
|
||||||
public static function shouldBlock(string $ua, array $securitySettings): ?string
|
public static function shouldBlock(string $ua, array $securitySettings): ?string
|
||||||
{
|
{
|
||||||
@@ -158,10 +182,15 @@ class BotGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate dynamic robots.txt content based on security settings
|
* Genereer dynamische robots.txt-inhoud op basis van de beveiligingsinstellingen.
|
||||||
*
|
*
|
||||||
* @param array $securitySettings Security configuration array
|
* Stelt globale regels op voor zoekmachines en voegt, indien AI-bots
|
||||||
* @return string Robots.txt content
|
* geblokkeerd worden, per AI-crawler een Disallow-blok toe.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $securitySettings Beveiligingsconfiguratie-array.
|
||||||
|
* @return string Robots.txt-inhoud.
|
||||||
*/
|
*/
|
||||||
public static function generateRobotsTxt(array $securitySettings): string
|
public static function generateRobotsTxt(array $securitySettings): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,93 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache interface voor key-value opslag met TTL-ondersteuning.
|
||||||
|
*
|
||||||
|
* Definieert een algemeen contract voor cache-implementaties binnen CodePress.
|
||||||
|
* Implementaties hoeven alleen lezen, schrijven, verwijderen en controleren
|
||||||
|
* van waarden op basis van een sleutel te ondersteunen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
interface CacheInterface {
|
interface CacheInterface {
|
||||||
|
/**
|
||||||
|
* Haal een waarde uit de cache op basis van de sleutel.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel van de cache-waarde.
|
||||||
|
* @return mixed De gecachte waarde, of null wanneer deze ontbreekt of verlopen is.
|
||||||
|
*/
|
||||||
public function get(string $key);
|
public function get(string $key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sla een waarde op in de cache met een time-to-live.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel waaronder de waarde wordt opgeslagen.
|
||||||
|
* @param mixed $value Te cachen waarde.
|
||||||
|
* @param int $ttl Levensduur in seconden. Default 3600.
|
||||||
|
* @return bool True bij succes, false bij een schrijffout.
|
||||||
|
*/
|
||||||
public function set(string $key, $value, int $ttl = 3600): bool;
|
public function set(string $key, $value, int $ttl = 3600): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwijder een waarde uit de cache.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel die verwijderd moet worden.
|
||||||
|
* @return bool True bij succes of wanneer de sleutel niet bestond.
|
||||||
|
*/
|
||||||
public function delete(string $key): bool;
|
public function delete(string $key): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wis alle waarden uit de cache.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True bij succes.
|
||||||
|
*/
|
||||||
public function clear(): bool;
|
public function clear(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleer of een sleutel bestaat en nog niet verlopen is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel die gecontroleerd moet worden.
|
||||||
|
* @return bool True wanneer de sleutel bestaat en geldig is.
|
||||||
|
*/
|
||||||
public function has(string $key): bool;
|
public function has(string $key): bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestandssysteem-gebaseerde cache-implementatie.
|
||||||
|
*
|
||||||
|
* Slaat gecachte waarden op als geserialiseerde bestanden in een map. Elke
|
||||||
|
* cache-sleutel wordt omgezet naar een md5-hash zodat ook lange of vreemde
|
||||||
|
* sleutels veilig als bestandsnaam gebruikt kunnen worden. Verlopen bestanden
|
||||||
|
* worden bij lezen of controleren automatisch verwijderd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class FileCache implements CacheInterface {
|
class FileCache implements CacheInterface {
|
||||||
|
/**
|
||||||
|
* Map waar de cache-bestanden in worden opgeslagen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private string $cacheDir;
|
private string $cacheDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maak een nieuwe file-cache aan en zorg dat de cachemap bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $cacheDir Pad naar de cachemap. Default '/tmp/codepress_cache'.
|
||||||
|
*/
|
||||||
public function __construct(string $cacheDir = '/tmp/codepress_cache') {
|
public function __construct(string $cacheDir = '/tmp/codepress_cache') {
|
||||||
$this->cacheDir = $cacheDir;
|
$this->cacheDir = $cacheDir;
|
||||||
if (!is_dir($this->cacheDir)) {
|
if (!is_dir($this->cacheDir)) {
|
||||||
@@ -18,6 +95,17 @@ class FileCache implements CacheInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal een waarde uit de cache.
|
||||||
|
*
|
||||||
|
* Wanneer het cache-bestand niet bestaat of verlopen is wordt null
|
||||||
|
* teruggegeven; in het laatste geval wordt het bestand direct verwijderd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel van de op te halen waarde.
|
||||||
|
* @return mixed De gecachte waarde of null.
|
||||||
|
*/
|
||||||
public function get(string $key) {
|
public function get(string $key) {
|
||||||
$file = $this->getCacheFile($key);
|
$file = $this->getCacheFile($key);
|
||||||
if (!file_exists($file)) {
|
if (!file_exists($file)) {
|
||||||
@@ -33,6 +121,16 @@ class FileCache implements CacheInterface {
|
|||||||
return $data['value'];
|
return $data['value'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sla een waarde op in de cache met de opgegeven TTL.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel waaronder wordt opgeslagen.
|
||||||
|
* @param mixed $value Te cachen waarde.
|
||||||
|
* @param int $ttl Levensduur in seconden. Default 3600.
|
||||||
|
* @return bool True bij succes, false bij een schrijffout.
|
||||||
|
*/
|
||||||
public function set(string $key, $value, int $ttl = 3600): bool {
|
public function set(string $key, $value, int $ttl = 3600): bool {
|
||||||
$file = $this->getCacheFile($key);
|
$file = $this->getCacheFile($key);
|
||||||
$data = [
|
$data = [
|
||||||
@@ -43,6 +141,17 @@ class FileCache implements CacheInterface {
|
|||||||
return file_put_contents($file, serialize($data)) !== false;
|
return file_put_contents($file, serialize($data)) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwijder een waarde uit de cache.
|
||||||
|
*
|
||||||
|
* Geeft true terug wanneer het bestand niet (meer) bestaat, zodat de
|
||||||
|
* aanroeper dit niet zelf hoeft te controleren.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel die verwijderd moet worden.
|
||||||
|
* @return bool True bij succes of wanneer de sleutel niet bestond.
|
||||||
|
*/
|
||||||
public function delete(string $key): bool {
|
public function delete(string $key): bool {
|
||||||
$file = $this->getCacheFile($key);
|
$file = $this->getCacheFile($key);
|
||||||
if (file_exists($file)) {
|
if (file_exists($file)) {
|
||||||
@@ -51,6 +160,15 @@ class FileCache implements CacheInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wis alle cache-bestanden in de cachemap.
|
||||||
|
*
|
||||||
|
* Submappen worden niet verwijderd, alleen losse bestanden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True bij succes.
|
||||||
|
*/
|
||||||
public function clear(): bool {
|
public function clear(): bool {
|
||||||
$files = glob($this->cacheDir . '/*');
|
$files = glob($this->cacheDir . '/*');
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
@@ -61,6 +179,17 @@ class FileCache implements CacheInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleer of een sleutel bestaat en nog geldig is.
|
||||||
|
*
|
||||||
|
* Let op: verlopen bestanden worden hier niet automatisch verwijderd;
|
||||||
|
* controleer vooraf eventueel via get() indien opruiming gewenst is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Sleutel die gecontroleerd moet worden.
|
||||||
|
* @return bool True wanneer de sleutel bestaat en de TTL nog niet verlopen is.
|
||||||
|
*/
|
||||||
public function has(string $key): bool {
|
public function has(string $key): bool {
|
||||||
$file = $this->getCacheFile($key);
|
$file = $this->getCacheFile($key);
|
||||||
if (!file_exists($file)) {
|
if (!file_exists($file)) {
|
||||||
@@ -71,6 +200,17 @@ class FileCache implements CacheInterface {
|
|||||||
return $data['expires'] > time();
|
return $data['expires'] > time();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bepaal het pad naar het cache-bestand voor een sleutel.
|
||||||
|
*
|
||||||
|
* De sleutel wordt gehashed met md5 zodat deze veilig als bestandsnaam
|
||||||
|
* gebruikt kan worden, ongeacht de lengte of tekenset van de sleutel.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Cache-sleutel.
|
||||||
|
* @return string Volledig pad naar het cache-bestand.
|
||||||
|
*/
|
||||||
private function getCacheFile(string $key): string {
|
private function getCacheFile(string $key): string {
|
||||||
return $this->cacheDir . '/' . md5($key) . '.cache';
|
return $this->cacheDir . '/' . md5($key) . '.cache';
|
||||||
}
|
}
|
||||||
|
|||||||
+517
-202
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,10 @@ class ContentAPI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all pages as a flat array of path => title pairs
|
* Get all content entries (mappen + bestanden) as a list of entry arrays.
|
||||||
*
|
*
|
||||||
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons']
|
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
|
||||||
|
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
@@ -29,18 +30,32 @@ class ContentAPI
|
|||||||
/**
|
/**
|
||||||
* Get a single page by path, including title, content, layout, and metadata
|
* Get a single page by path, including title, content, layout, and metadata
|
||||||
*
|
*
|
||||||
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1')
|
* De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
|
||||||
|
* Zonder extensie wordt het eerste bestaande bestand gekozen (md > php > html);
|
||||||
|
* met extensie wordt dat specifieke bestand gekozen.
|
||||||
|
*
|
||||||
|
* @param string $path Page path, al dan niet met extensie
|
||||||
* @return array|null Page data or null if not found
|
* @return array|null Page data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getPage(string $path): ?array
|
public function getPage(string $path): ?array
|
||||||
{
|
{
|
||||||
$contentDir = $this->cms->config['content_dir'];
|
$contentDir = $this->cms->config['content_dir'];
|
||||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
|
||||||
|
// Detecteer expliciete extensie en strip hem voor de basis
|
||||||
|
$preferredExt = null;
|
||||||
|
if (preg_match('/\.(md|php|html)$/i', $path, $m)) {
|
||||||
|
$preferredExt = strtolower($m[1]);
|
||||||
|
}
|
||||||
|
$path = preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||||
$filePath = $contentDir . '/' . $path;
|
$filePath = $contentDir . '/' . $path;
|
||||||
|
|
||||||
$extensions = ['md', 'php', 'html'];
|
// Probeer de gevraagde extensie eerst, dan de standaard volgorde
|
||||||
|
$order = ['md', 'php', 'html'];
|
||||||
|
if ($preferredExt !== null) {
|
||||||
|
$order = array_unique(array_merge([$preferredExt], $order));
|
||||||
|
}
|
||||||
$actualPath = null;
|
$actualPath = null;
|
||||||
foreach ($extensions as $ext) {
|
foreach ($order as $ext) {
|
||||||
if (file_exists($filePath . '.' . $ext)) {
|
if (file_exists($filePath . '.' . $ext)) {
|
||||||
$actualPath = $filePath . '.' . $ext;
|
$actualPath = $filePath . '.' . $ext;
|
||||||
break;
|
break;
|
||||||
@@ -141,14 +156,13 @@ class ContentAPI
|
|||||||
/**
|
/**
|
||||||
* Check if a page exists at the given path
|
* Check if a page exists at the given path
|
||||||
*
|
*
|
||||||
* @param string $path Page path without extension
|
* @param string $path Page path, al dan niet met extensie
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function pageExists(string $path): bool
|
public function pageExists(string $path): bool
|
||||||
{
|
{
|
||||||
$contentDir = $this->cms->config['content_dir'];
|
$contentDir = $this->cms->config['content_dir'];
|
||||||
$path = preg_replace('/\.(md|php|html)$/', '', $path);
|
$basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
|
||||||
$basePath = $contentDir . '/' . $path;
|
|
||||||
|
|
||||||
return file_exists($basePath . '.md')
|
return file_exists($basePath . '.md')
|
||||||
|| file_exists($basePath . '.php')
|
|| file_exists($basePath . '.php')
|
||||||
@@ -241,4 +255,21 @@ class ContentAPI
|
|||||||
{
|
{
|
||||||
return isset($_GET['search']);
|
return isset($_GET['search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the author metadata for the current page.
|
||||||
|
* Returns author_name, author_email and created from the page frontmatter.
|
||||||
|
*
|
||||||
|
* @return array Author metadata with keys: author_name, author_email, created
|
||||||
|
*/
|
||||||
|
public function getPageAuthor(): array
|
||||||
|
{
|
||||||
|
$page = $this->cms->getPage();
|
||||||
|
$metadata = $page['metadata'] ?? [];
|
||||||
|
return [
|
||||||
|
'author_name' => $metadata['author_name'] ?? '',
|
||||||
|
'author_email' => $metadata['author_email'] ?? '',
|
||||||
|
'created' => $metadata['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,46 @@
|
|||||||
* - ZIP backup of the entire content directory
|
* - ZIP backup of the entire content directory
|
||||||
* - Restore from an uploaded ZIP file
|
* - Restore from an uploaded ZIP file
|
||||||
* - Optional git-based versioning (init / commit / log / restore)
|
* - Optional git-based versioning (init / commit / log / restore)
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class ContentBackup
|
class ContentBackup
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Pad naar de content-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Pad naar de content-map.
|
||||||
|
*/
|
||||||
private string $contentDir;
|
private string $contentDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad naar de project-root.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Pad naar de project-root.
|
||||||
|
*/
|
||||||
private string $projectRoot;
|
private string $projectRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Of git beschikbaar is in de content-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var bool Of git beschikbaar is.
|
||||||
|
*/
|
||||||
private bool $gitAvailable;
|
private bool $gitAvailable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialiseer de ContentBackup-instantie.
|
||||||
|
*
|
||||||
|
* Stelt de content-map en project-root in en detecteert of git
|
||||||
|
* beschikbaar is op het systeem.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $contentDir Pad naar de content-map.
|
||||||
|
* @param string $projectRoot Optioneel. Pad naar de project-root. Default de map drie niveaus boven deze class.
|
||||||
|
*/
|
||||||
public function __construct(string $contentDir, string $projectRoot = '')
|
public function __construct(string $contentDir, string $projectRoot = '')
|
||||||
{
|
{
|
||||||
$this->contentDir = rtrim($contentDir, '/');
|
$this->contentDir = rtrim($contentDir, '/');
|
||||||
@@ -22,7 +55,11 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether git is available and the content dir is inside a git repo.
|
* Controleer of git beschikbaar is en de content-map in een git-repo zit.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien git beschikbaar is.
|
||||||
*/
|
*/
|
||||||
public function isGitAvailable(): bool
|
public function isGitAvailable(): bool
|
||||||
{
|
{
|
||||||
@@ -30,10 +67,15 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a ZIP backup of the content directory.
|
* Maak een ZIP-backup van de content-map.
|
||||||
*
|
*
|
||||||
* @param string $outputPath Where to write the ZIP file
|
* Controleert of ZipArchive beschikbaar is en de content-map bestaat,
|
||||||
* @return bool True on success
|
* en voegt recursief alle bestanden en mappen toe aan het ZIP-archief.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $outputPath Pad waar het ZIP-bestand geschreven moet worden.
|
||||||
|
* @return bool True bij succes, false bij falen.
|
||||||
*/
|
*/
|
||||||
public function createZipBackup(string $outputPath): bool
|
public function createZipBackup(string $outputPath): bool
|
||||||
{
|
{
|
||||||
@@ -54,10 +96,15 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore content from a ZIP file.
|
* Herstel content vanuit een ZIP-bestand.
|
||||||
*
|
*
|
||||||
* @param string $zipPath Path to the uploaded ZIP file
|
* Pakt het ZIP-bestand uit in een tijdelijke map, maakt een backup van de
|
||||||
* @return array ['success' => bool, 'message' => string]
|
* huidige content-map, kopieert de nieuwe content en ruimt oude backups op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $zipPath Pad naar het geüploade ZIP-bestand.
|
||||||
|
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||||
*/
|
*/
|
||||||
public function restoreFromZip(string $zipPath): array
|
public function restoreFromZip(string $zipPath): array
|
||||||
{
|
{
|
||||||
@@ -112,9 +159,14 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize git in the content directory.
|
* Initialiseer een git-repository in de content-map.
|
||||||
*
|
*
|
||||||
* @return array ['success' => bool, 'message' => string]
|
* Voert `git init` uit en configureert een default identiteit (user.email
|
||||||
|
* en user.name) zodat commits werken zonder globale git-config.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||||
*/
|
*/
|
||||||
public function gitInit(): array
|
public function gitInit(): array
|
||||||
{
|
{
|
||||||
@@ -135,10 +187,16 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Commit all changes in the content directory.
|
* Commit alle wijzigingen in de content-map.
|
||||||
*
|
*
|
||||||
* @param string $message Commit message
|
* Voegt alle bestanden toe met `git add -A`, controleert of er wijzigingen
|
||||||
* @return array ['success' => bool, 'message' => string]
|
* zijn via `git status --porcelain`, en commit met de opgegeven message.
|
||||||
|
* Bij een lege message wordt een standaardmessage gegenereerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $message Commit message.
|
||||||
|
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||||
*/
|
*/
|
||||||
public function gitCommit(string $message): array
|
public function gitCommit(string $message): array
|
||||||
{
|
{
|
||||||
@@ -173,10 +231,15 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the git log for the content directory.
|
* Haal de git-log op voor de content-map.
|
||||||
*
|
*
|
||||||
* @param int $limit Maximum number of entries
|
* Leest commits uit met `git log --pretty=format:%H|%h|%ai|%s` en parseert
|
||||||
* @return array ['success' => bool, 'commits' => array, 'message' => string]
|
* deze naar een array met hash, short, date en message per commit.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum aantal entries. Default 20.
|
||||||
|
* @return array{success:bool,commits:array<int,array{hash:string,short:string,date:string,message:string}>,message:string} Resultaat-array met success, commits en message.
|
||||||
*/
|
*/
|
||||||
public function gitLog(int $limit = 20): array
|
public function gitLog(int $limit = 20): array
|
||||||
{
|
{
|
||||||
@@ -221,10 +284,16 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore content to a specific git commit.
|
* Herstel content naar een specifieke git-commit.
|
||||||
*
|
*
|
||||||
* @param string $commitHash Commit hash to restore to
|
* Controleert de commit-hash, saniteert deze en voert
|
||||||
* @return array ['success' => bool, 'message' => string]
|
* `git checkout <hash> -- .` uit om de bestanden van die commit
|
||||||
|
* in de working tree te herstellen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $commitHash Commit-hash om naar te herstellen.
|
||||||
|
* @return array{success:bool,message:string} Resultaat-array met success en message.
|
||||||
*/
|
*/
|
||||||
public function gitRestore(string $commitHash): array
|
public function gitRestore(string $commitHash): array
|
||||||
{
|
{
|
||||||
@@ -251,7 +320,11 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the content directory has a git repository.
|
* Controleer of de content-map een git-repository bevat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien een .git-map aanwezig is.
|
||||||
*/
|
*/
|
||||||
public function hasGitRepo(): bool
|
public function hasGitRepo(): bool
|
||||||
{
|
{
|
||||||
@@ -262,6 +335,15 @@ class ContentBackup
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecteer of git beschikbaar is op het systeem.
|
||||||
|
*
|
||||||
|
* Voert `git --version` uit en controleert de exit-code.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien git uitvoerbaar is.
|
||||||
|
*/
|
||||||
private function detectGit(): bool
|
private function detectGit(): bool
|
||||||
{
|
{
|
||||||
$result = $this->execGitCapture(['--version']);
|
$result = $this->execGitCapture(['--version']);
|
||||||
@@ -269,9 +351,16 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a git command and capture output, error, and exit code separately.
|
* Voer een git-commando uit en capture output, error en exit-code apart.
|
||||||
*
|
*
|
||||||
* @return array ['output' => string, 'error' => string, 'exit' => int]
|
* Gebruikt proc_open met aparte pipes voor stdout, stderr en stdin.
|
||||||
|
* Alle argumenten worden via escapeshellarg ge-escaped.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array<int,string> $args Git-commando-argumenten.
|
||||||
|
* @param string|null $cwd Optioneel. Werkdirectory voor het git-proces.
|
||||||
|
* @return array{output:string,error:string,exit:int} Array met output, error en exit-code.
|
||||||
*/
|
*/
|
||||||
private function execGitCapture(array $args, ?string $cwd = null): array
|
private function execGitCapture(array $args, ?string $cwd = null): array
|
||||||
{
|
{
|
||||||
@@ -305,6 +394,19 @@ class ContentBackup
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voeg een map recursief toe aan een ZipArchive.
|
||||||
|
*
|
||||||
|
* Itereert over alle entries in $dir en voegt mappen (als lege dir) en
|
||||||
|
* bestanden toe onder de opgegeven $prefix in het ZIP-archief.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param ZipArchive $zip ZIP-archief om aan toe te voegen.
|
||||||
|
* @param string $dir Pad naar de bronmap.
|
||||||
|
* @param string $prefix Prefix (pad binnen het ZIP) voor de entries.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||||||
{
|
{
|
||||||
$entries = scandir($dir);
|
$entries = scandir($dir);
|
||||||
@@ -325,6 +427,18 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopieer een map recursief naar een doelmap.
|
||||||
|
*
|
||||||
|
* Maakt de doelmap aan indien nodig en kopieert alle bestanden en
|
||||||
|
* submappen vanuit de bronmap.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $src Pad naar de bronmap.
|
||||||
|
* @param string $dst Pad naar de doelmap.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function copyDir(string $src, string $dst): void
|
private function copyDir(string $src, string $dst): void
|
||||||
{
|
{
|
||||||
if (!is_dir($src)) {
|
if (!is_dir($src)) {
|
||||||
@@ -352,6 +466,16 @@ class ContentBackup
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwijder een map en alle inhoud recursief.
|
||||||
|
*
|
||||||
|
* Verwijdert eerst alle bestanden en submappen, daarna de map zelf.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $dir Pad naar de te verwijderen map.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function removeDir(string $dir): void
|
private function removeDir(string $dir): void
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
@@ -376,6 +500,17 @@ class ContentBackup
|
|||||||
rmdir($dir);
|
rmdir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruim oude content-backups op, waarbij alleen de laatste bewaard blijft.
|
||||||
|
*
|
||||||
|
* Zoekt backups via een glob-patroon op de content-mapnaam met .bak.*
|
||||||
|
* suffix, sorteert op wijzigingstijd (oudste eerst) en verwijdert alle
|
||||||
|
* backups behalve de meest recente.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function cleanupOldBackups(): void
|
private function cleanupOldBackups(): void
|
||||||
{
|
{
|
||||||
$parentDir = dirname($this->contentDir);
|
$parentDir = dirname($this->contentDir);
|
||||||
|
|||||||
+180
-11
@@ -1,13 +1,40 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GeoIP - Country lookup provider chain (Local binary, MMDB, and API)
|
* GeoIP country lookup provider chain.
|
||||||
|
*
|
||||||
|
* Ondersteunt drie providers voor het resolueren van een landcode op basis
|
||||||
|
* van een IP-adres: een lokale binaire database (DB-IP), een MaxMind MMDB
|
||||||
|
* bestand en een externe API. Providers vallen terug op de lokale database
|
||||||
|
* wanneer een lookup geen resultaat oplevert.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class GeoIP
|
class GeoIP
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* De analytics-configuratie uit config.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Bevat o.a. geoip_provider, geoip_mmdb_path en geoip_api_url.
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileCache instantie voor het cachen van API-lookups.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var FileCache|null
|
||||||
|
*/
|
||||||
private ?FileCache $cache = null;
|
private ?FileCache $cache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialiseert de GeoIP-provider met de analytics-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $analyticsConfig De "analytics" sectie uit config.json. Default lege array.
|
||||||
|
*/
|
||||||
public function __construct(array $analyticsConfig = [])
|
public function __construct(array $analyticsConfig = [])
|
||||||
{
|
{
|
||||||
$this->config = $analyticsConfig;
|
$this->config = $analyticsConfig;
|
||||||
@@ -16,10 +43,16 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve country code (2-letter ISO alpha-2, upper-case) from an IP address
|
* Resolves een 2-letter ISO alpha-2 landcode uit een IP-adres.
|
||||||
*
|
*
|
||||||
* @param string $ip IPv4 or IPv6 address
|
* De gekozen provider wordt gelezen uit de configuratie. Private en
|
||||||
* @return string|null Country code or null if unresolved/private
|
* gereserveerde ranges worden direct afgewezen. MMDB en API vallen
|
||||||
|
* terug op de lokale database bij een mislukte lookup.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip IPv4- of IPv6-adres.
|
||||||
|
* @return string|null Landcode in hoofdletters, of null bij onoplosbare/private adressen.
|
||||||
*/
|
*/
|
||||||
public function lookupCountry(string $ip): ?string
|
public function lookupCountry(string $ip): ?string
|
||||||
{
|
{
|
||||||
@@ -53,7 +86,15 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a country code; placeholder codes (ZZ/XX) count as unknown
|
* Normaliseert een landcode en verwerpt placeholder codes.
|
||||||
|
*
|
||||||
|
* Geldige codes zijn exact twee letters. De codes ZZ en XX worden
|
||||||
|
* beschouwd als onbekend en resulteren in null.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string|null $code Ruwe landcode uit een provider.
|
||||||
|
* @return string|null Genormaliseerde landcode of null indien ongeldig/onbekend.
|
||||||
*/
|
*/
|
||||||
private static function normalizeCode(?string $code): ?string
|
private static function normalizeCode(?string $code): ?string
|
||||||
{
|
{
|
||||||
@@ -65,7 +106,16 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary search lookup in local DB-IP IPv4/IPv6 binary files
|
* Binaire zoekopdracht in lokale DB-IP IPv4/IPv6 binaire bestanden.
|
||||||
|
*
|
||||||
|
* Leest de bestanden uit `admin/storage/geoip` en voert een binaire
|
||||||
|
* zoekopdracht uit op basis van het IP-adres. Ondersteunt zowel IPv4
|
||||||
|
* (10-byte records) als IPv6 (34-byte records).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip IPv4- of IPv6-adres.
|
||||||
|
* @return string|null Landcode in hoofdletters of null indien niet gevonden.
|
||||||
*/
|
*/
|
||||||
public function lookupLocal(string $ip): ?string
|
public function lookupLocal(string $ip): ?string
|
||||||
{
|
{
|
||||||
@@ -161,7 +211,16 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* External API lookup with caching
|
* Externe API-lookup met caching.
|
||||||
|
*
|
||||||
|
* Stuurt een HTTP-verzoek naar de geconfigureerde GeoIP API en cachet
|
||||||
|
* het resultaat zeven dagen in een FileCache. De API-URL mag een
|
||||||
|
* optionele API-key bevatten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip IPv4- of IPv6-adres.
|
||||||
|
* @return string|null Landcode of null indien de lookup faalt.
|
||||||
*/
|
*/
|
||||||
private function lookupApi(string $ip): ?string
|
private function lookupApi(string $ip): ?string
|
||||||
{
|
{
|
||||||
@@ -192,7 +251,16 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure-PHP MaxMind MMDB reader
|
* Pure-PHP MaxMind MMDB reader.
|
||||||
|
*
|
||||||
|
* Opent een MMDB-bestand en extraheert de landcode via de ingebouwde
|
||||||
|
* MMDBReader. Fouten worden gesilenced en resulteren in null.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip IPv4- of IPv6-adres.
|
||||||
|
* @param string $filePath Pad naar het MMDB-bestand.
|
||||||
|
* @return string|null Landcode of null bij een fout.
|
||||||
*/
|
*/
|
||||||
private function lookupMMDB(string $ip, string $filePath): ?string
|
private function lookupMMDB(string $ip, string $filePath): ?string
|
||||||
{
|
{
|
||||||
@@ -206,7 +274,12 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert 2-letter ISO country code to regional indicator flag emoji
|
* Zet een 2-letter ISO landcode om naar een regionale vlag-emoji.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string|null $code Landcode of null.
|
||||||
|
* @return string Vlag-emoji of een wereldbol bij ongeldige invoer.
|
||||||
*/
|
*/
|
||||||
public static function getCountryFlagEmoji(?string $code): string
|
public static function getCountryFlagEmoji(?string $code): string
|
||||||
{
|
{
|
||||||
@@ -222,7 +295,17 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get country name in Dutch or English
|
* Geeft de landnaam in Nederlands of Engels.
|
||||||
|
*
|
||||||
|
* Bevat een statische mapping van de meest voorkomende landcodes naar
|
||||||
|
* hun naam in de gevraagde taal. Onbekende codes worden ongewijzigd
|
||||||
|
* teruggegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string|null $code Landcode of null.
|
||||||
|
* @param string $lang Gewenste taal, 'nl' of 'en'. Default 'nl'.
|
||||||
|
* @return string Landnaam in de gevraagde taal of de code zelf.
|
||||||
*/
|
*/
|
||||||
public static function getCountryName(?string $code, string $lang = 'nl'): string
|
public static function getCountryName(?string $code, string $lang = 'nl'): string
|
||||||
{
|
{
|
||||||
@@ -275,14 +358,49 @@ class GeoIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in pure-PHP MaxMind DB Reader
|
* Ingebouwde pure-PHP MaxMind DB reader.
|
||||||
|
*
|
||||||
|
* Minimale implementatie van de MaxMind DB binary reader, nodig omdat de
|
||||||
|
* externe GeoIP2 dependency optioneel is. Ondersteunt 28-bit record sizes
|
||||||
|
* en de veelvoorkomende datatypes (pointer, string, map, uint, bool).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class MMDBReader
|
class MMDBReader
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Pad naar het MMDB-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private string $file;
|
private string $file;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestandshandle voor het MMDB-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var resource|null
|
||||||
|
*/
|
||||||
private $handle;
|
private $handle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata uit de MMDB-header.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Bevat o.a. node_count, record_size en ip_version.
|
||||||
|
*/
|
||||||
private array $meta;
|
private array $meta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opent het MMDB-bestand en laadt de metadata.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $file Pad naar het MMDB-bestand.
|
||||||
|
* @throws \InvalidArgumentException Als het bestand niet bestaat.
|
||||||
|
* @throws \RuntimeException Als het bestandformaat ongeldig is.
|
||||||
|
*/
|
||||||
public function __construct(string $file)
|
public function __construct(string $file)
|
||||||
{
|
{
|
||||||
if (!file_exists($file)) {
|
if (!file_exists($file)) {
|
||||||
@@ -293,6 +411,11 @@ class MMDBReader
|
|||||||
$this->loadMetadata();
|
$this->loadMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sluit de bestandshandle bij het vernietigen van de instantie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
public function __destruct()
|
public function __destruct()
|
||||||
{
|
{
|
||||||
if ($this->handle) {
|
if ($this->handle) {
|
||||||
@@ -300,6 +423,16 @@ class MMDBReader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laadt de MMDB-metadata vanaf het einde van het bestand.
|
||||||
|
*
|
||||||
|
* Zoekt naar de MaxMind marker en decodeert de daaropvolgende data.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException Als de marker niet gevonden wordt.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function loadMetadata(): void
|
private function loadMetadata(): void
|
||||||
{
|
{
|
||||||
$stat = fstat($this->handle);
|
$stat = fstat($this->handle);
|
||||||
@@ -319,6 +452,18 @@ class MMDBReader
|
|||||||
$this->meta = $this->decodeData($metaOffset)[0];
|
$this->meta = $this->decodeData($metaOffset)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voert een lookup uit voor een IP-adres in de MMDB-boom.
|
||||||
|
*
|
||||||
|
* Werkt door de binaire boom te doorlopen op basis van de bits van het
|
||||||
|
* IP-adres. IPv4-adressen in een IPv6-boom worden correct afgehandeld
|
||||||
|
* via de ipv4_instance_count metadata.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip IPv4- of IPv6-adres.
|
||||||
|
* @return array|null Gedecodeerde datarecord of null indien niet gevonden.
|
||||||
|
*/
|
||||||
public function get(string $ip): ?array
|
public function get(string $ip): ?array
|
||||||
{
|
{
|
||||||
$ipBin = inet_pton($ip);
|
$ipBin = inet_pton($ip);
|
||||||
@@ -355,6 +500,18 @@ class MMDBReader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest een kind-node (left/right) uit de MMDB-boom.
|
||||||
|
*
|
||||||
|
* Ondersteunt uitsluitend 28-bit record sizes.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $node Huidig node-index.
|
||||||
|
* @param int $bit Bitwaarde (0 voor left, 1 voor right).
|
||||||
|
* @param int $recordSize Record size in bits.
|
||||||
|
* @return int Doel-node-index of 0 bij niet-ondersteunde record sizes.
|
||||||
|
*/
|
||||||
private function readNode(int $node, int $bit, int $recordSize): int
|
private function readNode(int $node, int $bit, int $recordSize): int
|
||||||
{
|
{
|
||||||
$bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record
|
$bytesPerRecord = $recordSize / 4; // 28-bit -> 3.5 bytes per record
|
||||||
@@ -372,6 +529,18 @@ class MMDBReader
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodeert een MMDB-datarecord vanaf de gegeven offset.
|
||||||
|
*
|
||||||
|
* Ondersteunt pointer, string, double, uint, map en bool. De array
|
||||||
|
* return value bevat de gedecodeerde waarde en de cursorpositie na
|
||||||
|
* het record.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $offset Start-offset in het bestand.
|
||||||
|
* @return array Pair [mixed $value, int $newOffset].
|
||||||
|
*/
|
||||||
private function decodeData(int $offset): array
|
private function decodeData(int $offset): array
|
||||||
{
|
{
|
||||||
fseek($this->handle, $offset);
|
fseek($this->handle, $offset);
|
||||||
|
|||||||
+201
-35
@@ -1,34 +1,105 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LogManager - Dynamic logging for CodePress CMS
|
* Dynamische logging voor CodePress CMS.
|
||||||
*
|
*
|
||||||
* Supports three drivers:
|
* Ondersteunt drie drivers:
|
||||||
* - syslog: Send log entries to a remote syslog server (UDP) or local syslog.
|
* - syslog: Stuurt log entries naar een remote syslog-server (UDP) of de
|
||||||
* - sqlite: Store log entries in a SQLite database (default when no syslog
|
* lokale syslog.
|
||||||
* server is configured and SQLite is available).
|
* - sqlite: Slaat log entries op in een SQLite-database (standaard wanneer
|
||||||
* - file: Fallback to plain text files when SQLite is unavailable.
|
* geen syslog-server geconfigureerd is en SQLite beschikbaar is).
|
||||||
|
* - file: Fallback naar platte-tekstbestanden wanneer SQLite niet
|
||||||
|
* beschikbaar is.
|
||||||
*
|
*
|
||||||
* Which event types are recorded is controlled dynamically via the
|
* Welke event-types geregistreerd worden, wordt dynamisch gestuurd via de
|
||||||
* "logging.events" config section (admin, requests, errors, security,
|
* "logging.events" configuratiesectie (admin, requests, errors, security,
|
||||||
* content, system).
|
* content, system).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class LogManager {
|
class LogManager {
|
||||||
|
/**
|
||||||
|
* Event-type voor admin-acties.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_ADMIN = 'admin';
|
const EVENT_ADMIN = 'admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-type voor request-logging.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_REQUESTS = 'requests';
|
const EVENT_REQUESTS = 'requests';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-type voor fouten en waarschuwingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_ERRORS = 'errors';
|
const EVENT_ERRORS = 'errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-type voor beveiligingsgebeurtenissen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_SECURITY = 'security';
|
const EVENT_SECURITY = 'security';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-type voor content-wijzigingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_CONTENT = 'content';
|
const EVENT_CONTENT = 'content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-type voor systeemgebeurtenissen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const EVENT_SYSTEM = 'system';
|
const EVENT_SYSTEM = 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De "logging" configuratiesectie, of null indien niet geïnitialiseerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
private static $config = null;
|
private static $config = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-initialized PDO-verbinding met de SQLite-database, of null.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var \PDO|null
|
||||||
|
*/
|
||||||
private static $pdo = null;
|
private static $pdo = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad naar het SQLite-databasebestand, of null indien niet geïnitialiseerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
private static $dbPath = null;
|
private static $dbPath = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the log manager with the logging config section.
|
* Initialiseert de log-manager met de logging-configuratie.
|
||||||
*
|
*
|
||||||
* @param array $loggingConfig The "logging" section from config.json
|
* Slaat de configuratie op en stelt het pad naar de SQLite-database in
|
||||||
|
* onder `admin/storage/logs`.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $loggingConfig De "logging" sectie uit config.json.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function init(array $loggingConfig): void
|
public static function init(array $loggingConfig): void
|
||||||
{
|
{
|
||||||
@@ -37,7 +108,11 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether logging is enabled at all.
|
* Geeft aan of logging in het algemeen ingeschakeld is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien logging ingeschakeld is, anders false.
|
||||||
*/
|
*/
|
||||||
public static function isEnabled(): bool
|
public static function isEnabled(): bool
|
||||||
{
|
{
|
||||||
@@ -45,9 +120,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a given event type should be recorded.
|
* Geeft aan of een bepaald event-type geregistreerd moet worden.
|
||||||
*
|
*
|
||||||
* @param string $event One of the EVENT_* constants
|
* Controleert eerst of logging in het algemeen ingeschakeld is en
|
||||||
|
* daarna of het specifieke event aan staat in de configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $event Een van de EVENT_* constanten.
|
||||||
|
* @return bool True indien het event geregistreerd moet worden.
|
||||||
*/
|
*/
|
||||||
public static function isEventEnabled(string $event): bool
|
public static function isEventEnabled(string $event): bool
|
||||||
{
|
{
|
||||||
@@ -59,9 +140,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the local storage driver: 'sqlite' (falling back to 'file' if
|
* Geeft de actieve lokale opslag-driver.
|
||||||
* SQLite is unavailable). Syslog is an additional output, not a storage
|
*
|
||||||
* driver, so it never replaces local storage.
|
* Retourneert 'sqlite' wanneer SQLite beschikbaar is, anders 'file'.
|
||||||
|
* Syslog is een aanvullende output en vervangt nooit de lokale
|
||||||
|
* opslag, en komt dus nooit als driver terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string 'sqlite' of 'file'.
|
||||||
*/
|
*/
|
||||||
public static function getDriver(): string
|
public static function getDriver(): string
|
||||||
{
|
{
|
||||||
@@ -75,12 +162,20 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a log entry if the event type is enabled.
|
* Slaat een log-entry op indien het event-type ingeschakeld is.
|
||||||
*
|
*
|
||||||
* @param string $event Event type (EVENT_* constant)
|
* De entry wordt altijd lokaal opgeslagen (SQLite of file-fallback) zodat
|
||||||
* @param string $level Log level (info, warning, error, debug)
|
* de dynamische admin-log altijd entries bevat. Wanneer een syslog-host
|
||||||
* @param string $message Log message
|
* geconfigureerd is, wordt de entry tevens naar de remote syslog-server
|
||||||
* @param array $context Additional structured context
|
* gestuurd. Het IP-adres wordt uit de context of de RequestLogger gehaald.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $event Event-type (een EVENT_* constante).
|
||||||
|
* @param string $level Loglevel (info, warning, error, debug).
|
||||||
|
* @param string $message Logbericht.
|
||||||
|
* @param array $context Aanvullende gestructureerde context. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function log(string $event, string $level, string $message, array $context = []): void
|
public static function log(string $event, string $level, string $message, array $context = []): void
|
||||||
{
|
{
|
||||||
@@ -115,7 +210,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a log entry to a remote syslog server over UDP.
|
* Stuurt een log-entry naar een remote syslog-server over UDP.
|
||||||
|
*
|
||||||
|
* Bouwt een syslog-bericht op met facility, severity en ident en stuurt
|
||||||
|
* dit via een UDP-socket. Verbindings- en schrijffouten worden gesilenced.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $entry De te versturen log-entry.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function writeSyslog(array $entry): void
|
private static function writeSyslog(array $entry): void
|
||||||
{
|
{
|
||||||
@@ -138,7 +241,16 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a log entry in the SQLite database.
|
* Slaat een log-entry op in de SQLite-database.
|
||||||
|
*
|
||||||
|
* De context-array wordt als JSON opgeslagen. Wanneer de PDO-verbinding
|
||||||
|
* niet beschikbaar is of het schrijven faalt, wordt teruggevallen op
|
||||||
|
* de file-driver.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $entry De op te slaan log-entry.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function writeSqlite(array $entry): void
|
private static function writeSqlite(array $entry): void
|
||||||
{
|
{
|
||||||
@@ -167,7 +279,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a log entry to a plain text file (fallback driver).
|
* Voegt een log-entry toe aan een platte-tekstbestand (fallback-driver).
|
||||||
|
*
|
||||||
|
* Zorgt dat de doelmap bestaat en schrijft de entry met een vaste
|
||||||
|
* opmaak. Schrijffouten worden gesilenced.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $entry De toe te voegen log-entry.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function writeFile(array $entry): void
|
private static function writeFile(array $entry): void
|
||||||
{
|
{
|
||||||
@@ -182,7 +302,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get (and lazily create) the PDO connection to the SQLite database.
|
* Geeft (en creëert lazy) de PDO-verbinding met de SQLite-database.
|
||||||
|
*
|
||||||
|
* Maakt de tabel en indices aan indien deze nog niet bestaan. Bij een
|
||||||
|
* fout wordt de interne verbinding op null gezet en null teruggegeven,
|
||||||
|
* zodat de aanroeper kan terugvallen op de file-driver.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return \PDO|null PDO-verbinding of null indien SQLite niet beschikbaar/faalt.
|
||||||
*/
|
*/
|
||||||
private static function getPdo(): ?\PDO
|
private static function getPdo(): ?\PDO
|
||||||
{
|
{
|
||||||
@@ -222,7 +350,11 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the SQLite PDO driver is available.
|
* Geeft aan of de SQLite PDO-driver beschikbaar is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien PDO en de sqlite-driver beschikbaar zijn.
|
||||||
*/
|
*/
|
||||||
private static function sqliteAvailable(): bool
|
private static function sqliteAvailable(): bool
|
||||||
{
|
{
|
||||||
@@ -230,7 +362,16 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a facility name to its syslog numeric value.
|
* Vertaalt een facility-naam naar de bijbehorende numerieke syslog-waarde.
|
||||||
|
*
|
||||||
|
* Ondersteunt de standaard facilities (kern, user, mail, daemon, auth,
|
||||||
|
* syslog, lpr, news, uucp, cron, authpriv, ftp) en local0 t/m local7.
|
||||||
|
* Onbekende faciliteiten vallen terug op local0 (16).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $facility Naam van de facility. Default 'local0' wordt door aanroeper gezet.
|
||||||
|
* @return int Numerieke facility-waarde.
|
||||||
*/
|
*/
|
||||||
private static function syslogFacility(string $facility): int
|
private static function syslogFacility(string $facility): int
|
||||||
{
|
{
|
||||||
@@ -245,7 +386,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a log level to its syslog severity value.
|
* Vertaalt een loglevel naar de bijbehorende syslog-severity.
|
||||||
|
*
|
||||||
|
* Ondersteunt debug t/m emergency. Onbekende levels vallen terug op
|
||||||
|
* info (6). De invoer wordt case-insensitive vergeleken.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $level Loglevel (debug, info, notice, warning, error, ...).
|
||||||
|
* @return int Numerieke severity-waarde (0 t/m 7).
|
||||||
*/
|
*/
|
||||||
private static function syslogSeverity(string $level): int
|
private static function syslogSeverity(string $level): int
|
||||||
{
|
{
|
||||||
@@ -263,13 +412,22 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query recent log entries from the active store.
|
* Zoekt recente log-entries op uit de actieve opslag.
|
||||||
*
|
*
|
||||||
* @param int $limit Number of entries to return
|
* Bij de SQLite-driver wordt een geparametriseerde query uitgevoerd met
|
||||||
* @param string|null $event Optional event filter
|
* optionele filters op event, level en een zoekterm op het bericht.
|
||||||
* @param string|null $level Optional level filter (info, warning, error, ...)
|
* Bij de file-driver wordt het logbestand uitgelezen en met een regex
|
||||||
* @param string|null $search Optional text search on the message
|
* geparseerd; de resultaten worden gefilterd en oudste eerst teruggegeven.
|
||||||
* @return array List of log entries (newest first)
|
* Resultaten worden altijd nieuwste eerst geretourneerd (SQLite via
|
||||||
|
* ORDER BY id DESC, file via array_reverse).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $limit Aantal terug te geven entries. Default 200.
|
||||||
|
* @param string|null $event Optioneel event-filter. Default null (geen filter).
|
||||||
|
* @param string|null $level Optioneel levelfilter (info, warning, error, ...). Default null.
|
||||||
|
* @param string|null $search Optionele tekstzoekopdracht op het bericht. Default null.
|
||||||
|
* @return array<int, array<string, mixed>> Lijst met log-entries (nieuwste eerst).
|
||||||
*/
|
*/
|
||||||
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
|
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
|
||||||
{
|
{
|
||||||
@@ -340,7 +498,15 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all stored log entries.
|
* Wist alle opgeslagen log-entries.
|
||||||
|
*
|
||||||
|
* Bij de SQLite-driver worden alle rijen uit de logs-tabel verwijderd.
|
||||||
|
* Bij de file-driver wordt het logbestand leeggemaakt. Schrijffouten
|
||||||
|
* worden gesilenced.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function clear(): void
|
public static function clear(): void
|
||||||
{
|
{
|
||||||
|
|||||||
+108
-32
@@ -1,27 +1,75 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Simple Logger Class for CodePress CMS
|
* Eenvoudige logger voor CodePress CMS.
|
||||||
*
|
*
|
||||||
* Provides structured logging with log levels and file output.
|
* Voorziet in gestructureerde logging met loglevels en bestandsuitvoer.
|
||||||
|
* Logregels worden naar een tekstbestand geschreven en, indien aanwezig,
|
||||||
|
* tevens door de dynamische LogManager gerouteerd.
|
||||||
*
|
*
|
||||||
* @package CodePress
|
* @since 2.6.5
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
|
/**
|
||||||
|
* Loglevel voor debug-boodschappen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const DEBUG = 'DEBUG';
|
const DEBUG = 'DEBUG';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loglevel voor informatieve boodschappen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const INFO = 'INFO';
|
const INFO = 'INFO';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loglevel voor waarschuwingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const WARNING = 'WARNING';
|
const WARNING = 'WARNING';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loglevel voor fouten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
const ERROR = 'ERROR';
|
const ERROR = 'ERROR';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad naar het logbestand, of null indien nog niet geïnitialiseerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
private static $logFile = null;
|
private static $logFile = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Of debug-logging actief is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
private static $debugMode = false;
|
private static $debugMode = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize logger
|
* Initialiseert de logger.
|
||||||
*
|
*
|
||||||
* @param string $logFile Path to log file
|
* Stelt het pad naar het logbestand in en zorgt dat de bijbehorende
|
||||||
* @param bool $debugMode Enable debug logging
|
* map bestaat. Indien geen pad wordt opgegeven valt de logger terug
|
||||||
|
* op een standaardpad onder de core-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string|null $logFile Pad naar het logbestand. Default null (standaardlocatie).
|
||||||
|
* @param bool $debugMode Of debug-logging actief is. Default false.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function init($logFile = null, $debugMode = false) {
|
public static function init($logFile = null, $debugMode = false) {
|
||||||
if ($logFile === null) {
|
if ($logFile === null) {
|
||||||
@@ -39,10 +87,13 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log debug message (only in debug mode)
|
* Logt een debug-boodschap (enkel in debug-modus).
|
||||||
*
|
*
|
||||||
* @param string $message Message to log
|
* @since 2.6.5
|
||||||
* @param array $context Additional context
|
*
|
||||||
|
* @param string $message Boodschap die gelogd moet worden.
|
||||||
|
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function debug($message, $context = []) {
|
public static function debug($message, $context = []) {
|
||||||
if (self::$debugMode) {
|
if (self::$debugMode) {
|
||||||
@@ -51,41 +102,59 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log info message
|
* Logt een informatieve boodschap.
|
||||||
*
|
*
|
||||||
* @param string $message Message to log
|
* @since 2.6.5
|
||||||
* @param array $context Additional context
|
*
|
||||||
|
* @param string $message Boodschap die gelogd moet worden.
|
||||||
|
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function info($message, $context = []) {
|
public static function info($message, $context = []) {
|
||||||
self::write(self::INFO, $message, $context);
|
self::write(self::INFO, $message, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log warning message
|
* Logt een waarschuwing.
|
||||||
*
|
*
|
||||||
* @param string $message Message to log
|
* @since 2.6.5
|
||||||
* @param array $context Additional context
|
*
|
||||||
|
* @param string $message Boodschap die gelogd moet worden.
|
||||||
|
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function warning($message, $context = []) {
|
public static function warning($message, $context = []) {
|
||||||
self::write(self::WARNING, $message, $context);
|
self::write(self::WARNING, $message, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log error message
|
* Logt een fout.
|
||||||
*
|
*
|
||||||
* @param string $message Message to log
|
* @since 2.6.5
|
||||||
* @param array $context Additional context
|
*
|
||||||
|
* @param string $message Boodschap die gelogd moet worden.
|
||||||
|
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function error($message, $context = []) {
|
public static function error($message, $context = []) {
|
||||||
self::write(self::ERROR, $message, $context);
|
self::write(self::ERROR, $message, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write log entry to file
|
* Schrijft een logregel naar het bestand en routeert deze door LogManager.
|
||||||
*
|
*
|
||||||
* @param string $level Log level
|
* De context-array wordt als JSON aan de logregel toegevoegd. Fouten bij
|
||||||
* @param string $message Message to log
|
* het schrijven worden gesilenced (graceful degradation). Wanneer de
|
||||||
* @param array $context Additional context
|
* LogManager class beschikbaar is, wordt de entry tevens naar de
|
||||||
|
* dynamische log gestuurd (errors/warnings naar EVENT_ERRORS, verder
|
||||||
|
* naar EVENT_SYSTEM).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $level Loglevel (DEBUG, INFO, WARNING, ERROR).
|
||||||
|
* @param string $message Boodschap die gelogd moet worden.
|
||||||
|
* @param array $context Aanvullende contextuele data. Default lege array.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function write($level, $message, $context = []) {
|
private static function write($level, $message, $context = []) {
|
||||||
if (self::$logFile === null) {
|
if (self::$logFile === null) {
|
||||||
@@ -109,18 +178,22 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get log file path
|
* Geeft het pad naar het logbestand.
|
||||||
*
|
*
|
||||||
* @return string Log file path
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string|null Pad naar het logbestand of null indien niet geïnitialiseerd.
|
||||||
*/
|
*/
|
||||||
public static function getLogFile() {
|
public static function getLogFile() {
|
||||||
return self::$logFile;
|
return self::$logFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear log file
|
* Verwijdert het logbestand.
|
||||||
*
|
*
|
||||||
* @return bool Success status
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien het bestand succesvol verwijderd is, anders false.
|
||||||
*/
|
*/
|
||||||
public static function clear() {
|
public static function clear() {
|
||||||
if (self::$logFile && file_exists(self::$logFile)) {
|
if (self::$logFile && file_exists(self::$logFile)) {
|
||||||
@@ -130,13 +203,16 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get last N lines from log file
|
* Geeft de laatste N regels uit het logbestand.
|
||||||
*
|
*
|
||||||
* Reads the file backwards in chunks so large log files never have to be
|
* Leest het bestand achterwaarts in chunks zodat grote logbestanden
|
||||||
* loaded into memory in their entirety.
|
* nooit volledig in het geheugen geladen hoeven te worden. De regels
|
||||||
|
* worden inclusief afsluitende newlines teruggegeven, oudste eerst.
|
||||||
*
|
*
|
||||||
* @param int $lines Number of lines to retrieve
|
* @since 2.6.5
|
||||||
* @return array Log lines (including trailing newlines, oldest first)
|
*
|
||||||
|
* @param int $lines Aantal terug te halen regels. Default 100.
|
||||||
|
* @return array<string> Logregels (oudste eerst) of lege array indien niet beschikbaar.
|
||||||
*/
|
*/
|
||||||
public static function tail($lines = 100) {
|
public static function tail($lines = 100) {
|
||||||
if (!self::$logFile || !file_exists(self::$logFile)) {
|
if (!self::$logFile || !file_exists(self::$logFile)) {
|
||||||
|
|||||||
@@ -1,16 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limiteert het aantal acties per identifier binnen een tijdvenster.
|
||||||
|
*
|
||||||
|
* Houdt per identifier (zoals een IP of gebruikersnaam) bij hoeveel pogingen
|
||||||
|
* binnen een geconfigureerd tijdvenster zijn gedaan. Oudere pogingen vallen
|
||||||
|
* buiten het venster en worden genegeerd. De teller wordt opgeslagen in een
|
||||||
|
* CacheInterface-implementatie, standaard FileCache.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class RateLimiter {
|
class RateLimiter {
|
||||||
|
/**
|
||||||
|
* Maximum aantal toegestane pogingen binnen het tijdvenster.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
private int $maxAttempts;
|
private int $maxAttempts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lengte van het tijdvenster in seconden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
private int $timeWindow;
|
private int $timeWindow;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-implementatie waarin de pogingen worden bijgehouden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var CacheInterface
|
||||||
|
*/
|
||||||
private CacheInterface $cache;
|
private CacheInterface $cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maak een nieuwe RateLimiter aan.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $maxAttempts Maximum aantal pogingen binnen het tijdvenster. Default 10.
|
||||||
|
* @param int $timeWindow Lengte van het tijdvenster in seconden. Default 60.
|
||||||
|
* @param CacheInterface|null $cache Optioneel een eigen cache-implementatie; bij null wordt FileCache gebruikt.
|
||||||
|
*/
|
||||||
public function __construct(int $maxAttempts = 10, int $timeWindow = 60, ?CacheInterface $cache = null) {
|
public function __construct(int $maxAttempts = 10, int $timeWindow = 60, ?CacheInterface $cache = null) {
|
||||||
$this->maxAttempts = $maxAttempts;
|
$this->maxAttempts = $maxAttempts;
|
||||||
$this->timeWindow = $timeWindow;
|
$this->timeWindow = $timeWindow;
|
||||||
$this->cache = $cache ?? new FileCache();
|
$this->cache = $cache ?? new FileCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bepaal of een nieuwe poging voor de identifier is toegestaan.
|
||||||
|
*
|
||||||
|
* Verwijder eerst pogingen die buiten het tijdvenster vallen. Wanneer het
|
||||||
|
* aantal resterende pogingen onder het maximum ligt wordt de huidige
|
||||||
|
* poging geregistreerd en true teruggegeven; anders false.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $identifier Unieke sleutel voor de te beperken actor (bijv. IP of gebruikersnaam).
|
||||||
|
* @return bool True wanneer de poging is toegestaan, false wanneer het limiet is bereikt.
|
||||||
|
*/
|
||||||
public function isAllowed(string $identifier): bool {
|
public function isAllowed(string $identifier): bool {
|
||||||
$key = 'ratelimit_' . md5($identifier);
|
$key = 'ratelimit_' . md5($identifier);
|
||||||
$attempts = $this->cache->get($key) ?? [];
|
$attempts = $this->cache->get($key) ?? [];
|
||||||
@@ -32,6 +83,17 @@ class RateLimiter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geef het aantal nog resterende pogingen voor de identifier.
|
||||||
|
*
|
||||||
|
* Telt alleen pogingen die binnen het tijdvenster vallen. Het resultaat
|
||||||
|
* is nooit negatief.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $identifier Unieke sleutel voor de te beperken actor.
|
||||||
|
* @return int Aantal resterende pogingen; minimaal 0.
|
||||||
|
*/
|
||||||
public function getRemainingAttempts(string $identifier): int {
|
public function getRemainingAttempts(string $identifier): int {
|
||||||
$key = 'ratelimit_' . md5($identifier);
|
$key = 'ratelimit_' . md5($identifier);
|
||||||
$attempts = $this->cache->get($key) ?? [];
|
$attempts = $this->cache->get($key) ?? [];
|
||||||
@@ -44,6 +106,14 @@ class RateLimiter {
|
|||||||
return max(0, $this->maxAttempts - count($attempts));
|
return max(0, $this->maxAttempts - count($attempts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wis alle geregistreerde pogingen voor een identifier.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $identifier Unieke sleutel voor de te beperken actor.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function reset(string $identifier): void {
|
public function reset(string $identifier): void {
|
||||||
$key = 'ratelimit_' . md5($identifier);
|
$key = 'ratelimit_' . md5($identifier);
|
||||||
$this->cache->delete($key);
|
$this->cache->delete($key);
|
||||||
|
|||||||
@@ -1,14 +1,57 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logging en analyse van inkomende requests.
|
||||||
|
*
|
||||||
|
* Biedt functionaliteit om inkomende requests naar een logbestand te
|
||||||
|
* schrijven, IP-adressen te anonimiseren, IP's tegen lijsten/CIDR-ranges
|
||||||
|
* te matchen, het client-IP te bepalen en bezoekerstype (bot/mens/AI)
|
||||||
|
* te detecteren.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class RequestLogger
|
class RequestLogger
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Pad naar het logbestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private string $logFile;
|
private string $logFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maak een nieuwe RequestLogger aan.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $logFile Pad naar het te gebruiken logbestand.
|
||||||
|
*/
|
||||||
public function __construct(string $logFile)
|
public function __construct(string $logFile)
|
||||||
{
|
{
|
||||||
$this->logFile = $logFile;
|
$this->logFile = $logFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schrijf een request-regel naar het logbestand.
|
||||||
|
*
|
||||||
|
* De gebruikersagent en referrer worden ontdaan van controle-tekens en
|
||||||
|
* afgekapt tot 500 tekens. De landcode wordt alleen opgenomen wanneer
|
||||||
|
* deze exact twee tekens lang is. De mapstructuur wordt automatisch
|
||||||
|
* aangemaakt indien deze nog niet bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $page Opgevraagde pagina of route.
|
||||||
|
* @param string $ip Client IP-adres.
|
||||||
|
* @param string $userAgent HTTP User-Agent header.
|
||||||
|
* @param string $referrer HTTP Referer header.
|
||||||
|
* @param string $host Hostnaam van het request.
|
||||||
|
* @param string $acceptLanguage HTTP Accept-Language header.
|
||||||
|
* @param string $status Statusaanduiding van het request. Default 'ok'.
|
||||||
|
* @param string|null $country Tweeletterige landcode of null. Default null.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok', ?string $country = null): void
|
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok', ?string $country = null): void
|
||||||
{
|
{
|
||||||
$dir = dirname($this->logFile);
|
$dir = dirname($this->logFile);
|
||||||
@@ -25,7 +68,16 @@ class RequestLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mask the last octet (IPv4) or last block (IPv6) of an IP address
|
* Anonimiseer het laatste deel van een IP-adres.
|
||||||
|
*
|
||||||
|
* Voor IPv4 wordt het laatste octet vervangen door 'x'. Voor IPv6 worden
|
||||||
|
* alleen de eerste vier blokken behouden en de rest vervangen door '::x'.
|
||||||
|
* Ongeldige adressen worden ongewijzigd teruggegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ip Het te anonimiseren IP-adres.
|
||||||
|
* @return string Het geanonimiseerde IP-adres of het origineel bij een ongeldig adres.
|
||||||
*/
|
*/
|
||||||
public static function anonymizeIp(string $ip): string
|
public static function anonymizeIp(string $ip): string
|
||||||
{
|
{
|
||||||
@@ -45,13 +97,17 @@ class RequestLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
|
* Controleer of een IP overeenkomt met een entry uit een lijst van IP's of CIDR-ranges.
|
||||||
*
|
*
|
||||||
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
|
* Ondersteunt exacte IPv4/IPv6-adressen en CIDR-notatie (bijv. 192.168.0.0/16).
|
||||||
|
* Bij een prefix-lengte van 0 komt ieder IP van dezelfde adresfamilie overeen.
|
||||||
|
* Adresfamilies (IPv4 vs IPv6) worden niet onderling gemengd.
|
||||||
*
|
*
|
||||||
* @param string $ip The client IP to test
|
* @since 2.6.5
|
||||||
* @param array $list List of IPs and/or CIDR ranges
|
*
|
||||||
* @return bool True if the IP matches any entry
|
* @param string $ip Het te testen client IP-adres.
|
||||||
|
* @param string[] $list Lijst van IP-adressen en/of CIDR-ranges.
|
||||||
|
* @return bool True wanneer het IP met een entry overeenkomt.
|
||||||
*/
|
*/
|
||||||
public static function ipMatchesList(string $ip, array $list): bool
|
public static function ipMatchesList(string $ip, array $list): bool
|
||||||
{
|
{
|
||||||
@@ -117,6 +173,18 @@ class RequestLogger
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bepaal het client IP-adres uit de beschikbare request-headers.
|
||||||
|
*
|
||||||
|
* Doorloopt in twee passes een vaste set headers (zoals CF-Connecting-IP,
|
||||||
|
* X-Real-IP, X-Forwarded-For en REMOTE_ADDR). De eerste pass geeft
|
||||||
|
* voorrang aan geldige publieke IP's en slaat interne/reserved ranges
|
||||||
|
* over; de tweede pass is een fallback voor lokale ontwikkelomgevingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Het gedetecteerde client IP-adres, of '127.0.0.1' als fallback.
|
||||||
|
*/
|
||||||
public static function getClientIp(): string
|
public static function getClientIp(): string
|
||||||
{
|
{
|
||||||
$headerKeys = [
|
$headerKeys = [
|
||||||
@@ -165,6 +233,21 @@ class RequestLogger
|
|||||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecteer het type bezoeker aan de hand van de User-Agent.
|
||||||
|
*
|
||||||
|
* Wanneer de BotGuard-class beschikbaar is wordt de UA geïdentificeerd
|
||||||
|
* en een passend label, badge-kleur en Bootstrap-icon teruggegeven
|
||||||
|
* (ai, search, scraper, generieke bot, lege UA of mens). Bij een
|
||||||
|
* ingelogde gebruiker wordt de naam aan het label toegevoegd. Zonder
|
||||||
|
* BotGuard wordt een algemene bezoeker teruggegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $ua De User-Agent string.
|
||||||
|
* @param string $user Optionele gebruikersnaam; bij 'Gast' of leeg wordt geen naam toegevoegd. Default ''.
|
||||||
|
* @return array<string,string> Associatieve array met keys: type, label, badge, icon.
|
||||||
|
*/
|
||||||
public static function detectVisitorInfo(string $ua, string $user = ''): array
|
public static function detectVisitorInfo(string $ua, string $user = ''): array
|
||||||
{
|
{
|
||||||
if (class_exists('BotGuard')) {
|
if (class_exists('BotGuard')) {
|
||||||
@@ -202,6 +285,18 @@ class RequestLogger
|
|||||||
return ['type' => 'unknown', 'label' => 'Bezoeker', 'badge' => 'secondary', 'icon' => 'bi-person'];
|
return ['type' => 'unknown', 'label' => 'Bezoeker', 'badge' => 'secondary', 'icon' => 'bi-person'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecteer of het huidige request afkomstig is van een bekende bot.
|
||||||
|
*
|
||||||
|
* Leest de User-Agent uit de request-headers en identificeert deze via
|
||||||
|
* BotGuard wanneer beschikbaar. Alleen de categorieën 'ai', 'search'
|
||||||
|
* en 'scraper' worden als bot beschouwd en als uppercase string
|
||||||
|
* teruggegeven; anders null.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string|null Boticategorie ('AI', 'SEARCH' of 'SCRAPER') of null bij geen bot.
|
||||||
|
*/
|
||||||
public static function detectBot(): ?string
|
public static function detectBot(): ?string
|
||||||
{
|
{
|
||||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
@@ -214,6 +309,20 @@ class RequestLogger
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lees de laatste regels uit het logbestand en parse deze.
|
||||||
|
*
|
||||||
|
* De regels worden geparseerd volgens het vaste logformaat en omgezet
|
||||||
|
* naar associatieve arrays met tijd, IP, gebruiker, bezoeker-info, taal,
|
||||||
|
* pagina, UA, referrer, status en landcode. Regels waarvan de host leeg
|
||||||
|
* is of een IP/cli-waarde bevat worden als gebruiker 'Gast' gemarkeerd.
|
||||||
|
* Het resultaat is nieuwste-regel-eerst.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param int $lines Aantal te lezen regels vanaf het einde. Default 100.
|
||||||
|
* @return array<int,array<string,mixed>> Geparseerde logregels, nieuwste eerst. Lege array bij ontbrekend bestand.
|
||||||
|
*/
|
||||||
public function getLogs(int $lines = 100): array
|
public function getLogs(int $lines = 100): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->logFile)) {
|
if (!file_exists($this->logFile)) {
|
||||||
|
|||||||
+169
-45
@@ -5,24 +5,62 @@ use Twig\Environment;
|
|||||||
use Twig\Loader\FilesystemLoader;
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeManager - Resolves and renders the active theme
|
* Beheert het actieve thema en de rendering hiervan.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Verantwoordelijkheden:
|
||||||
* - Resolve the active theme directory from config
|
* - Bepaalt de actieve thema-map aan de hand van de configuratie.
|
||||||
* - Load theme.json (title, default_layout, template mapping, colors)
|
* - Laadt theme.json (titel, default_layout, template-mapping, kleuren).
|
||||||
* - Build a Twig environment rooted at the theme directory
|
* - Bouwt een Twig-omgeving geworteld in de thema-map.
|
||||||
* - Compile theme SCSS to CSS (in assets/css_compiled/)
|
* - Compileert thema-SCSS naar CSS (in assets/css_compiled/).
|
||||||
* - Map a requested layout to a concrete .twig template, falling back
|
* - Mapt een aangevraagde layout naar een concreet .twig-template, met
|
||||||
* to the theme's default_layout when the layout is unknown
|
* fallback naar de default_layout van het thema wanneer de layout
|
||||||
|
* onbekend is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class ThemeManager {
|
class ThemeManager {
|
||||||
|
/**
|
||||||
|
* Volledige CMS-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Bevat o.a. 'theme_dir' en 'theme'.
|
||||||
|
*/
|
||||||
private $config;
|
private $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absoluut pad naar de actieve thema-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private $themeDir;
|
private $themeDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruwe theme.json configuratie-array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
private $themeConfig;
|
private $themeConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twig-omgeving voor het renderen van thema-templates.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var Environment
|
||||||
|
*/
|
||||||
private $twig;
|
private $twig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
|
* Constructor: initialiseert de thema-manager met de CMS-configuratie.
|
||||||
|
*
|
||||||
|
* Stelt de thema-map, theme.json-configuratie en een Twig-omgeving in.
|
||||||
|
* De Twig-cache staat uit en auto-escape is uitgeschakeld (de thema's
|
||||||
|
* beheren eigen escaping waar nodig).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $config Volledige CMS-configuratie; moet 'theme_dir' en 'theme' bevatten.
|
||||||
*/
|
*/
|
||||||
public function __construct(array $config) {
|
public function __construct(array $config) {
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
@@ -37,55 +75,85 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the absolute path of the active theme directory
|
* Geeft het absolute pad van de actieve thema-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de thema-map.
|
||||||
*/
|
*/
|
||||||
public function getThemeDir(): string {
|
public function getThemeDir(): string {
|
||||||
return $this->themeDir;
|
return $this->themeDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the raw theme.json config array
|
* Geeft de ruwe theme.json configuratie-array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Theme.json inhoud, of lege array bij afwezigheid.
|
||||||
*/
|
*/
|
||||||
public function getThemeConfig(): array {
|
public function getThemeConfig(): array {
|
||||||
return $this->themeConfig;
|
return $this->themeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the theme title (from theme.json 'title' or 'name')
|
* Geeft de thema-titel.
|
||||||
|
*
|
||||||
|
* Leest eerst 'title' uit theme.json, dan 'name', en als laatste
|
||||||
|
* fallback de mapnaam van het thema.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Thematische titel.
|
||||||
*/
|
*/
|
||||||
public function getTitle(): string {
|
public function getTitle(): string {
|
||||||
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
|
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the theme config section (default_template, background settings, etc.)
|
* Geeft de 'config'-sectie van theme.json.
|
||||||
|
*
|
||||||
|
* Bevat o.a. default_template en achtergrondinstellingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Config-sectie, of lege array bij afwezigheid.
|
||||||
*/
|
*/
|
||||||
public function getConfig(): array {
|
public function getConfig(): array {
|
||||||
return $this->themeConfig['config'] ?? [];
|
return $this->themeConfig['config'] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the theme template section (layout key => .twig file mapping)
|
* Geeft de 'template'-sectie van theme.json.
|
||||||
|
*
|
||||||
|
* Bevat de mapping van layout-key naar .twig-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,string> Layout-key => .twig-bestand mapping.
|
||||||
*/
|
*/
|
||||||
public function getTemplates(): array {
|
public function getTemplates(): array {
|
||||||
return $this->themeConfig['template'] ?? [];
|
return $this->themeConfig['template'] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the .twig template file for a requested layout.
|
* Bepaalt het .twig-templatebestand voor een aangevraagde layout.
|
||||||
*
|
*
|
||||||
* Templates are defined in theme.json under the "template" section:
|
* Templates worden gedefinieerd in theme.json onder de "template"-sectie:
|
||||||
* { "template": { "full_content": "full_content.twig", ... } }
|
* { "template": { "full_content": "full_content.twig", ... } }
|
||||||
* The default template is defined in the "config" section:
|
* De standaardtemplate staat in de "config"-sectie:
|
||||||
* { "config": { "default_template": "full_content", ... } }
|
* { "config": { "default_template": "full_content", ... } }
|
||||||
*
|
*
|
||||||
* Priority:
|
* Prioriteit:
|
||||||
* 1. If the layout is a known key in the "template" section, use its mapped file.
|
* 1. Als de layout een bekende key is in de "template"-sectie, gebruik
|
||||||
* 2. Otherwise fall back to config.default_template.
|
* het bijbehorende bestand.
|
||||||
* 3. Final safety net: full_content.twig.
|
* 2. Anders fallback naar config.default_template.
|
||||||
|
* 3. Laatste veiligheidsnet: full_content.twig.
|
||||||
*
|
*
|
||||||
* @param string $layout Requested layout key (e.g. 'left_sidebar')
|
* @since 2.6.5
|
||||||
* @return string Template name usable by the Twig loader
|
*
|
||||||
|
* @param string $layout Aangevraagde layout-key (bijv. 'left_sidebar').
|
||||||
|
* @return string Templacenaam bruikbaar voor de Twig-loader.
|
||||||
*/
|
*/
|
||||||
public function getTemplateForLayout(string $layout): string {
|
public function getTemplateForLayout(string $layout): string {
|
||||||
$layout = trim($layout);
|
$layout = trim($layout);
|
||||||
@@ -111,16 +179,23 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of available layout keys defined in the "template" section.
|
* Geeft de lijst met beschikbare layout-keys uit de "template"-sectie.
|
||||||
*
|
*
|
||||||
* @return array List of layout keys
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string> Lijst met layout-keys.
|
||||||
*/
|
*/
|
||||||
public function getLayouts(): array {
|
public function getLayouts(): array {
|
||||||
return array_keys($this->getTemplates());
|
return array_keys($this->getTemplates());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a template file exists in the theme directory
|
* Controleert of een templatebestand bestaat in de thema-map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $file Bestandsnaam van het template (relatief t.o.v. thema-map).
|
||||||
|
* @return bool True als het bestand bestaat.
|
||||||
*/
|
*/
|
||||||
private function templateExists(string $file): bool {
|
private function templateExists(string $file): bool {
|
||||||
$path = $this->themeDir . '/' . ltrim($file, '/');
|
$path = $this->themeDir . '/' . ltrim($file, '/');
|
||||||
@@ -128,11 +203,13 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a layout template with the given data.
|
* Rendert een layout-template met de gegeven data.
|
||||||
*
|
*
|
||||||
* @param string $layout Requested layout key
|
* @since 2.6.5
|
||||||
* @param array $data Template variables
|
*
|
||||||
* @return string Rendered HTML
|
* @param string $layout Aangevraagde layout-key.
|
||||||
|
* @param array $data Template-variabelen.
|
||||||
|
* @return string Gerenderde HTML.
|
||||||
*/
|
*/
|
||||||
public function render(string $layout, array $data): string {
|
public function render(string $layout, array $data): string {
|
||||||
$template = $this->getTemplateForLayout($layout);
|
$template = $this->getTemplateForLayout($layout);
|
||||||
@@ -140,11 +217,17 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile the theme's SCSS to CSS (cached by source mtime).
|
* Compileert de SCSS van het thema naar CSS (gecacht op bron-mtime).
|
||||||
* Compiles from assets/scss/theme.scss to assets/css_compiled/theme.css
|
|
||||||
*
|
*
|
||||||
* @param bool $force Force recompilation
|
* Compileert vanuit assets/scss/theme.scss naar assets/css_compiled/theme.css.
|
||||||
* @return string|null Absolute path to the compiled CSS, or null if none
|
* De gecompileerde CSS en een .mtime-cachebestand worden op alleen-lezen
|
||||||
|
* (0444) gezet om handmatige aanpassingen te voorkomen. Bij een fout
|
||||||
|
* wordt deze gelogd en null teruggegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param bool $force Forceer hercompilatie ongeacht de cache. Default false.
|
||||||
|
* @return string|null Absoluut pad naar de gecompileerde CSS, of null bij fout/afwezigheid.
|
||||||
*/
|
*/
|
||||||
public function compileCss(bool $force = false): ?string {
|
public function compileCss(bool $force = false): ?string {
|
||||||
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||||
@@ -193,8 +276,15 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the public URL for the theme CSS.
|
* Geeft de publieke URL voor de thema-CSS.
|
||||||
* Uses assets/css_compiled/theme.css (compiled from SCSS by scssphp).
|
*
|
||||||
|
* Gebruikt assets/css_compiled/theme.css (gecompileerd vanuit SCSS door
|
||||||
|
* scssphp). Indien de gecompileerde CSS nog niet bestaat, wordt deze
|
||||||
|
* alsnog gecompileerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string|null Publieke CSS-URL, of null indien niet beschikbaar.
|
||||||
*/
|
*/
|
||||||
public function getCssUrl(): ?string {
|
public function getCssUrl(): ?string {
|
||||||
$compiledCss = $this->themeDir . '/assets/css_compiled/theme.css';
|
$compiledCss = $this->themeDir . '/assets/css_compiled/theme.css';
|
||||||
@@ -212,7 +302,11 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the public URL for the theme JS, or null if unavailable.
|
* Geeft de publieke URL voor de thema-JS, of null indien niet beschikbaar.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string|null Publieke JS-URL, of null indien niet beschikbaar.
|
||||||
*/
|
*/
|
||||||
public function getJsUrl(): ?string {
|
public function getJsUrl(): ?string {
|
||||||
$jsFile = $this->themeDir . '/assets/js/theme.js';
|
$jsFile = $this->themeDir . '/assets/js/theme.js';
|
||||||
@@ -223,7 +317,12 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get public URL for a theme asset.
|
* Geeft de publieke URL voor een thema-asset.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $assetPath Pad naar de asset binnen de assets/-map van het thema.
|
||||||
|
* @return string Publieke URL in de vorm '/themes/<naam>/assets/<path>'.
|
||||||
*/
|
*/
|
||||||
private function getThemeAssetUrl(string $assetPath): string {
|
private function getThemeAssetUrl(string $assetPath): string {
|
||||||
$themeName = basename($this->themeDir);
|
$themeName = basename($this->themeDir);
|
||||||
@@ -231,14 +330,22 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if theme has SCSS source file.
|
* Controleert of het thema een SCSS-bronbestand heeft.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True als assets/scss/theme.scss bestaat.
|
||||||
*/
|
*/
|
||||||
public function hasScss(): bool {
|
public function hasScss(): bool {
|
||||||
return is_file($this->themeDir . '/assets/scss/theme.scss');
|
return is_file($this->themeDir . '/assets/scss/theme.scss');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if compiled CSS is newer than SCSS source.
|
* Controleert of de gecompileerde CSS nieuwer is dan de SCSS-bron.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True als de CSS-_mtime >= SCSS-mtime, anders false.
|
||||||
*/
|
*/
|
||||||
public function isScssCompiled(): bool {
|
public function isScssCompiled(): bool {
|
||||||
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
||||||
@@ -252,15 +359,24 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if theme has manual CSS file.
|
* Controleert of het thema een handmatig CSS-bestand heeft.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True als assets/css/theme.css bestaat.
|
||||||
*/
|
*/
|
||||||
public function hasManualCss(): bool {
|
public function hasManualCss(): bool {
|
||||||
return is_file($this->themeDir . '/assets/css/theme.css');
|
return is_file($this->themeDir . '/assets/css/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of CSS files in theme assets/css/ directory.
|
* Geeft een lijst met CSS-bestanden in de assets/css/-map van het thema.
|
||||||
* Excludes css_compiled directory.
|
*
|
||||||
|
* De css_compiled-map wordt uitgesloten.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string> Lijst met publieke CSS-URLs.
|
||||||
*/
|
*/
|
||||||
public function getCssFiles(): array {
|
public function getCssFiles(): array {
|
||||||
$cssDir = $this->themeDir . '/assets/css';
|
$cssDir = $this->themeDir . '/assets/css';
|
||||||
@@ -281,7 +397,11 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of JS files in theme assets/js/ directory.
|
* Geeft een lijst met JS-bestanden in de assets/js/-map van het thema.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string> Lijst met publieke JS-URLs.
|
||||||
*/
|
*/
|
||||||
public function getJsFiles(): array {
|
public function getJsFiles(): array {
|
||||||
$jsDir = $this->themeDir . '/assets/js';
|
$jsDir = $this->themeDir . '/assets/js';
|
||||||
@@ -302,7 +422,11 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get URL for favicon if it exists.
|
* Geeft de URL voor de favicon indien deze bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string|null Publieke favicon-URL, of null indien niet beschikbaar.
|
||||||
*/
|
*/
|
||||||
public function getFaviconUrl(): ?string {
|
public function getFaviconUrl(): ?string {
|
||||||
$faviconFile = $this->themeDir . '/assets/img/favicon.svg';
|
$faviconFile = $this->themeDir . '/assets/img/favicon.svg';
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* Configuratie-loader voor CodePress CMS.
|
||||||
|
*
|
||||||
|
* Laadt config.json (of maakt deze aan op basis van config.json.example of
|
||||||
|
* een ingebouwde standaardconfiguratie), voegt ontbrekende secties
|
||||||
|
* (security, analytics, logging) samen met defaults, converteert relatieve
|
||||||
|
* content_dir-paden naar absoluut, laadt de actieve theme-configuratie en
|
||||||
|
* returnt de uiteindelijke configuratie-array. Bij falen volgt een
|
||||||
|
* minimale fallback-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @package CodePress
|
||||||
|
*/
|
||||||
|
|
||||||
// Simple configuration loader
|
// Simple configuration loader
|
||||||
$configJsonPath = __DIR__ . '/../../config.json';
|
$configJsonPath = __DIR__ . '/../../config.json';
|
||||||
$configExamplePath = __DIR__ . '/../../config.json.example';
|
$configExamplePath = __DIR__ . '/../../config.json.example';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config.json automatisch aanmaken als deze nog niet bestaat.
|
||||||
|
*
|
||||||
|
* Kopieert config.json.example indien aanwezig; anders wordt een ingebouwde
|
||||||
|
* standaardconfiguratie weggeschreven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Auto-create config.json if it does not exist
|
// Auto-create config.json if it does not exist
|
||||||
if (!file_exists($configJsonPath)) {
|
if (!file_exists($configJsonPath)) {
|
||||||
if (file_exists($configExamplePath)) {
|
if (file_exists($configExamplePath)) {
|
||||||
@@ -83,6 +104,15 @@ if (!file_exists($configJsonPath)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config.json inladen en ontbrekende secties aanvullen met defaults.
|
||||||
|
*
|
||||||
|
* Voegt default-waarden samen voor security, analytics en logging,
|
||||||
|
* garandeert de excluded_ips-standaardlijst en converteert relatieve
|
||||||
|
* content_dir-paden naar absoluut.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
if (file_exists($configJsonPath)) {
|
if (file_exists($configJsonPath)) {
|
||||||
$jsonContent = file_get_contents($configJsonPath);
|
$jsonContent = file_get_contents($configJsonPath);
|
||||||
$config = json_decode($jsonContent, true);
|
$config = json_decode($jsonContent, true);
|
||||||
@@ -150,6 +180,11 @@ if (file_exists($configJsonPath)) {
|
|||||||
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actieve theme-configuratie laden vanuit theme.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load active theme
|
// Load active theme
|
||||||
$activeTheme = $config['active_theme'] ?? 'default';
|
$activeTheme = $config['active_theme'] ?? 'default';
|
||||||
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
||||||
@@ -166,6 +201,11 @@ if (file_exists($configJsonPath)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimale fallback-configuratie wanneer config.json niet geladen kon worden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Fallback to minimal config
|
// Fallback to minimal config
|
||||||
return [
|
return [
|
||||||
'site_title' => 'CodePress',
|
'site_title' => 'CodePress',
|
||||||
|
|||||||
+56
-16
@@ -1,37 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CodePress CMS Core Loader
|
* CodePress CMS Core Loader.
|
||||||
*
|
*
|
||||||
* This file serves as the central entry point for the CodePress CMS core system.
|
* Centraal bootstrap-bestand voor de CodePress CMS-core. Laadt alle
|
||||||
* It loads all essential classes and configuration needed for the CMS to function.
|
* essentiële classes en configuratie die nodig zijn voor het CMS:
|
||||||
|
* configuratie, Composer-autoloader, core utility classes, logger,
|
||||||
|
* plugin-systeem, ContentAPI en de hoofdclass CodePressCMS.
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architectuur:
|
||||||
* - config.php: Configuration loader that merges default settings with config.json
|
* - config.php: configuratie-loader die defaults samenvoegt met config.json.
|
||||||
* - ThemeManager.php: Twig-based theme rendering + SCSS compilation
|
* - ThemeManager.php: Twig-based theme rendering + SCSS-compilatie.
|
||||||
* - CodePressCMS.php: Main CMS class handling content, navigation, search, and rendering
|
* - CodePressCMS.php: hoofdclass voor content, navigation, search en rendering.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Gebruik:
|
||||||
* This file is included by public/index.php, which then:
|
* Dit bestand wordt geïnclude door public/index.php, dat vervolgens:
|
||||||
* 1. Loads configuration from config.php
|
* 1. Configuratie laadt vanuit config.php.
|
||||||
* 2. Creates a new CodePressCMS instance with the config
|
* 2. Een CodePressCMS-instantie aanmaakt met de config.
|
||||||
* 3. Calls render() to output the complete page
|
* 3. render() aanroept om de pagina uit te voeren.
|
||||||
*
|
*
|
||||||
* The separation allows for:
|
* @since 2.6.5
|
||||||
* - Clean public entry point (public/index.php)
|
* @package CodePress
|
||||||
* - Reusable core components
|
|
||||||
* - Proper class organization with PHPDoc documentation
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuratie-systeem laden: defaults en config.json-merge.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load configuration system - handles default settings and config.json merging
|
// Load configuration system - handles default settings and config.json merging
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer-autoloader laden indien aanwezig.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load Composer autoloader
|
// Load Composer autoloader
|
||||||
$autoloader = dirname(__DIR__, 2) . '/vendor/autoload.php';
|
$autoloader = dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||||
if (file_exists($autoloader)) {
|
if (file_exists($autoloader)) {
|
||||||
require_once $autoloader;
|
require_once $autoloader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core utility classes laden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load core utility classes
|
// Load core utility classes
|
||||||
require_once 'class/Cache.php';
|
require_once 'class/Cache.php';
|
||||||
require_once 'class/RateLimiter.php';
|
require_once 'class/RateLimiter.php';
|
||||||
@@ -41,21 +56,46 @@ require_once 'class/GeoIP.php';
|
|||||||
require_once 'class/Analytics.php';
|
require_once 'class/Analytics.php';
|
||||||
require_once 'class/ThemeManager.php';
|
require_once 'class/ThemeManager.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger-klassen laden voor gestructureerde logging met log levels.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load Logger class - structured logging with log levels
|
// Load Logger class - structured logging with log levels
|
||||||
require_once 'class/Logger.php';
|
require_once 'class/Logger.php';
|
||||||
require_once 'class/LogManager.php';
|
require_once 'class/LogManager.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-systeem laden (interfaces, API's en PluginManager).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load Plugin system
|
// Load Plugin system
|
||||||
require_once 'plugin/PluginAPIInterface.php';
|
require_once 'plugin/PluginAPIInterface.php';
|
||||||
require_once 'plugin/CMSAPI.php';
|
require_once 'plugin/CMSAPI.php';
|
||||||
require_once 'plugin/AdminPluginAPI.php';
|
require_once 'plugin/AdminPluginAPI.php';
|
||||||
require_once 'plugin/PluginManager.php';
|
require_once 'plugin/PluginManager.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentAPI laden: CMS-data-toegang voor PHP content-bestanden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load ContentAPI class - provides CMS data access for PHP content files
|
// Load ContentAPI class - provides CMS data access for PHP content files
|
||||||
require_once 'class/ContentAPI.php';
|
require_once 'class/ContentAPI.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoofdclass laden: content parsing, navigation, search en page rendering.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Load main CMS class - handles content parsing, navigation, search, and page rendering
|
// Load main CMS class - handles content parsing, navigation, search, and page rendering
|
||||||
require_once 'class/CodePressCMS.php';
|
require_once 'class/CodePressCMS.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger initialiseren (debug-mode kan via config worden ingeschakeld).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Initialize logger (debug mode can be enabled in config)
|
// Initialize logger (debug mode can be enabled in config)
|
||||||
Logger::init();
|
Logger::init();
|
||||||
@@ -1,22 +1,84 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight API wrapper for plugins in the admin context.
|
* Lichtgewicht API-wrapper voor plugins in de admin-context.
|
||||||
* Provides read-only access to the site config (no CMS instance needed).
|
*
|
||||||
|
* Biedt alleen-lezen toegang tot de site-config (zonder CMS-instance nodig).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class AdminPluginAPI implements PluginAPIInterface
|
class AdminPluginAPI implements PluginAPIInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* De site-configuratie als associatieve array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string,mixed>
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Het absolute pad naar de project-root.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private string $projectRoot;
|
private string $projectRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De actieve admin-taalcode (bijv. 'nl', 'en').
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $adminLanguage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De admin PluginManager-instantie, optioneel via setPluginManager geïnjecteerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginManager|null
|
||||||
|
*/
|
||||||
|
private ?PluginManager $pluginManager = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construeer een AdminPluginAPI-instantie met de opgegeven site-config.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $siteConfig De site-configuratie.
|
||||||
|
* @param string $projectRoot Optioneel absoluut pad naar de project-root.
|
||||||
|
*/
|
||||||
public function __construct(array $siteConfig, string $projectRoot = '')
|
public function __construct(array $siteConfig, string $projectRoot = '')
|
||||||
{
|
{
|
||||||
$this->config = $siteConfig;
|
$this->config = $siteConfig;
|
||||||
$this->projectRoot = $projectRoot !== '' ? $projectRoot : dirname(__DIR__, 3);
|
$this->projectRoot = $projectRoot !== '' ? $projectRoot : dirname(__DIR__, 3);
|
||||||
|
$this->adminLanguage = $siteConfig['admin_language']
|
||||||
|
?? ($siteConfig['language']['default'] ?? 'nl');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get configuration value using dot notation.
|
* Injecteer de admin PluginManager zodat plugins hun eigen vertalingen
|
||||||
|
* via dezelfde fallback-keten kunnen resolven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param PluginManager $pm De admin PluginManager-instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setPluginManager(PluginManager $pm): void
|
||||||
|
{
|
||||||
|
$this->pluginManager = $pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal een configuratiewaarde op via dot-notatie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||||
|
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||||
|
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||||
*/
|
*/
|
||||||
public function getConfig(string $key, $default = null)
|
public function getConfig(string $key, $default = null)
|
||||||
{
|
{
|
||||||
@@ -34,7 +96,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the project root directory.
|
* Haal de project-rootdirectory op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de project-root.
|
||||||
*/
|
*/
|
||||||
public function getProjectRoot(): string
|
public function getProjectRoot(): string
|
||||||
{
|
{
|
||||||
@@ -42,15 +108,30 @@ class AdminPluginAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content directory path.
|
* Haal het pad naar de contentdirectory op als absoluut pad.
|
||||||
|
*
|
||||||
|
* Relatieve paden uit config.json (bijv. 'content') worden afgezet tegen
|
||||||
|
* de project-root, conform het gedrag van cms/core/config.php.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de contentdirectory.
|
||||||
*/
|
*/
|
||||||
public function getContentDir(): string
|
public function getContentDir(): string
|
||||||
{
|
{
|
||||||
return $this->config['content_dir'] ?? ($this->projectRoot . '/content');
|
$dir = $this->config['content_dir'] ?? ($this->projectRoot . '/content');
|
||||||
|
if ($dir !== '' && $dir[0] !== DIRECTORY_SEPARATOR && !preg_match('#^[A-Za-z]:[/\\\\]#', $dir)) {
|
||||||
|
$dir = $this->projectRoot . '/' . $dir;
|
||||||
|
}
|
||||||
|
return rtrim($dir, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the plugins directory path.
|
* Haal het pad naar de pluginsdirectory op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de pluginsdirectory.
|
||||||
*/
|
*/
|
||||||
public function getPluginsDir(): string
|
public function getPluginsDir(): string
|
||||||
{
|
{
|
||||||
@@ -58,7 +139,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of enabled plugins.
|
* Haal de lijst met ingeschakelde plugins op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,string> Lijst met ingeschakelde plugin-namen.
|
||||||
*/
|
*/
|
||||||
public function getEnabledPlugins(): array
|
public function getEnabledPlugins(): array
|
||||||
{
|
{
|
||||||
@@ -66,7 +151,11 @@ class AdminPluginAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the version info from version.php.
|
* Haal de versie-informatie uit version.php op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||||
*/
|
*/
|
||||||
public function getVersionInfo(): array
|
public function getVersionInfo(): array
|
||||||
{
|
{
|
||||||
@@ -79,4 +168,55 @@ class AdminPluginAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
return ['version' => '0.0.0'];
|
return ['version' => '0.0.0'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de actieve admin-taalcode op (bijv. 'nl', 'en').
|
||||||
|
*
|
||||||
|
* System-plugins gebruiken deze om hun vertalingen te resolven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De actieve admin-taalcode.
|
||||||
|
*/
|
||||||
|
public function getAdminLanguage(): string
|
||||||
|
{
|
||||||
|
return $this->adminLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de vertalingen voor een plugin op in de admin-context.
|
||||||
|
*
|
||||||
|
* Fallback-keten (afgehandeld door PluginManager):
|
||||||
|
* aangevraagde taal -> default_language van de plugin -> lege array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Override-taal; standaard de actieve admin-taal.
|
||||||
|
* @return array<string,string> Vertalingen als [key => value].
|
||||||
|
*/
|
||||||
|
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
|
||||||
|
{
|
||||||
|
if ($this->pluginManager === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$lang = $lang ?? $this->adminLanguage;
|
||||||
|
return $this->pluginManager->getPluginTranslations($pluginName, $lang, 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertaal een enkele sleutel voor een plugin in de admin-context.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Vertaalsleutel.
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Override-taal; standaard de actieve admin-taal.
|
||||||
|
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||||
|
*/
|
||||||
|
public function t(string $key, string $pluginName, ?string $lang = null): string
|
||||||
|
{
|
||||||
|
$t = $this->getPluginTranslations($pluginName, $lang);
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+270
-20
@@ -1,16 +1,63 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Front-end plugin-API voor content-plugins.
|
||||||
|
*
|
||||||
|
* Biedt toegang tot de CodePressCMS-instance, pagina-informatie, menu, zoekresultaten,
|
||||||
|
* vertalingen en bestandsoperaties binnen de front-end context.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class CMSAPI implements PluginAPIInterface
|
class CMSAPI implements PluginAPIInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* De CodePressCMS-hoofdinstantie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var CodePressCMS
|
||||||
|
*/
|
||||||
private CodePressCMS $cms;
|
private CodePressCMS $cms;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De front-end PluginManager-instantie, optioneel via setPluginManager geïnjecteerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginManager|null
|
||||||
|
*/
|
||||||
|
private ?PluginManager $pluginManager = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construeer een CMSAPI-instantie met de opgegeven CodePressCMS.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param CodePressCMS $cms De CodePressCMS-hoofdinstantie.
|
||||||
|
*/
|
||||||
public function __construct(CodePressCMS $cms)
|
public function __construct(CodePressCMS $cms)
|
||||||
{
|
{
|
||||||
$this->cms = $cms;
|
$this->cms = $cms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current page information
|
* Injecteer de front-end PluginManager zodat content-plugins hun eigen
|
||||||
|
* vertalingen via dezelfde fallback-keten kunnen resolven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param PluginManager $pm De front-end PluginManager-instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setPluginManager(PluginManager $pm): void
|
||||||
|
{
|
||||||
|
$this->pluginManager = $pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de informatie van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,mixed> Pagina-informatie van de huidige pagina.
|
||||||
*/
|
*/
|
||||||
public function getCurrentPage(): array
|
public function getCurrentPage(): array
|
||||||
{
|
{
|
||||||
@@ -18,7 +65,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current page title
|
* Haal de titel van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De titel van de huidige pagina, of lege string.
|
||||||
*/
|
*/
|
||||||
public function getCurrentPageTitle(): string
|
public function getCurrentPageTitle(): string
|
||||||
{
|
{
|
||||||
@@ -27,7 +78,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current page content
|
* Haal de inhoud van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De HTML-inhoud van de huidige pagina, of lege string.
|
||||||
*/
|
*/
|
||||||
public function getCurrentPageContent(): string
|
public function getCurrentPageContent(): string
|
||||||
{
|
{
|
||||||
@@ -36,7 +91,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current page URL
|
* Haal de URL van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De URL van de huidige pagina inclusief query-parameters.
|
||||||
*/
|
*/
|
||||||
public function getCurrentPageUrl(): string
|
public function getCurrentPageUrl(): string
|
||||||
{
|
{
|
||||||
@@ -46,7 +105,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get menu structure
|
* Haal de menustructuur op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>> De menustructuur.
|
||||||
*/
|
*/
|
||||||
public function getMenu(): array
|
public function getMenu(): array
|
||||||
{
|
{
|
||||||
@@ -54,7 +117,13 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get configuration value
|
* Haal een configuratiewaarde op via dot-notatie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||||
|
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||||
|
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||||
*/
|
*/
|
||||||
public function getConfig(string $key, $default = null)
|
public function getConfig(string $key, $default = null)
|
||||||
{
|
{
|
||||||
@@ -72,7 +141,70 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get translation
|
* Haal de project-rootdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de project-root.
|
||||||
|
*/
|
||||||
|
public function getProjectRoot(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal het pad naar de contentdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* De CMS-config lost relatieve content_dir-waarden al op
|
||||||
|
* (zie cms/core/config.php), dus deze wordt as-is teruggegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de contentdirectory.
|
||||||
|
*/
|
||||||
|
public function getContentDir(): string
|
||||||
|
{
|
||||||
|
return rtrim($this->cms->config['content_dir'] ?? ($this->getProjectRoot() . '/content'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal het pad naar de pluginsdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de pluginsdirectory.
|
||||||
|
*/
|
||||||
|
public function getPluginsDir(): string
|
||||||
|
{
|
||||||
|
return $this->getProjectRoot() . '/plugins';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de versie-informatie uit version.php op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||||
|
*/
|
||||||
|
public function getVersionInfo(): array
|
||||||
|
{
|
||||||
|
$versionFile = $this->getProjectRoot() . '/version.php';
|
||||||
|
if (file_exists($versionFile)) {
|
||||||
|
$data = include $versionFile;
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['version' => '0.0.0'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal een vertaling op voor de opgegeven sleutel.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Vertaalsleutel.
|
||||||
|
* @return string De vertaalde string, of $key indien niet gevonden.
|
||||||
*/
|
*/
|
||||||
public function translate(string $key): string
|
public function translate(string $key): string
|
||||||
{
|
{
|
||||||
@@ -80,7 +212,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current language
|
* Haal de huidige content-taal op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De huidige taalcode (bijv. 'nl', 'en').
|
||||||
*/
|
*/
|
||||||
public function getCurrentLanguage(): string
|
public function getCurrentLanguage(): string
|
||||||
{
|
{
|
||||||
@@ -88,7 +224,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is on homepage
|
* Controleer of de gebruiker zich op de homepage bevindt.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien de huidige pagina de default_page is.
|
||||||
*/
|
*/
|
||||||
public function isHomepage(): bool
|
public function isHomepage(): bool
|
||||||
{
|
{
|
||||||
@@ -98,7 +238,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file info for current page
|
* Haal de bestandsinformatie van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null Bestandsinformatie of null indien niet aanwezig.
|
||||||
*/
|
*/
|
||||||
public function getCurrentPageFileInfo(): ?array
|
public function getCurrentPageFileInfo(): ?array
|
||||||
{
|
{
|
||||||
@@ -107,7 +251,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get breadcrumb data
|
* Haal de breadcrumb-HTML op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string De gegenereerde breadcrumb-HTML.
|
||||||
*/
|
*/
|
||||||
public function getBreadcrumb(): string
|
public function getBreadcrumb(): string
|
||||||
{
|
{
|
||||||
@@ -115,7 +263,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if content directory has content
|
* Controleer of de contentdirectory inhoud bevat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien de contentdirectory niet leeg is.
|
||||||
*/
|
*/
|
||||||
public function hasContent(): bool
|
public function hasContent(): bool
|
||||||
{
|
{
|
||||||
@@ -123,7 +275,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get search results if searching
|
* Haal de zoekresultaten op indien een zoekopdracht actief is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>> De zoekresultaten, of een lege array.
|
||||||
*/
|
*/
|
||||||
public function getSearchResults(): array
|
public function getSearchResults(): array
|
||||||
{
|
{
|
||||||
@@ -134,7 +290,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if currently searching
|
* Controleer of momenteel een zoekopdracht wordt uitgevoerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien de search-query-parameter aanwezig is.
|
||||||
*/
|
*/
|
||||||
public function isSearching(): bool
|
public function isSearching(): bool
|
||||||
{
|
{
|
||||||
@@ -142,7 +302,11 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available languages
|
* Haal de beschikbare talen op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,string> Lijst met beschikbare taalcodes.
|
||||||
*/
|
*/
|
||||||
public function getAvailableLanguages(): array
|
public function getAvailableLanguages(): array
|
||||||
{
|
{
|
||||||
@@ -150,7 +314,13 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create URL for page
|
* Maak een URL voor een pagina.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $page Pagina-identifier.
|
||||||
|
* @param string|null $lang Taalcode; standaard de huidige content-taal.
|
||||||
|
* @return string De gegenereerde pagina-URL met query-parameters.
|
||||||
*/
|
*/
|
||||||
public function createUrl(string $page, ?string $lang = null): string
|
public function createUrl(string $page, ?string $lang = null): string
|
||||||
{
|
{
|
||||||
@@ -159,7 +329,15 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute PHP file and capture output
|
* Voer een PHP-bestand uit en vang de output op.
|
||||||
|
*
|
||||||
|
* Het bestand moet binnen de CMS-directory liggen om willekeurige
|
||||||
|
* file-inclusion te voorkomen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $filePath Absoluut pad naar het PHP-bestand.
|
||||||
|
* @return string De opgevangen output, of lege string indien ongeldig.
|
||||||
*/
|
*/
|
||||||
public function executePhpFile(string $filePath): string
|
public function executePhpFile(string $filePath): string
|
||||||
{
|
{
|
||||||
@@ -180,7 +358,12 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get content from PHP/HTML/Markdown file
|
* Haal de inhoud op uit een PHP-, HTML- of Markdown-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $filePath Pad naar het bestand.
|
||||||
|
* @return string De bestandsinhoud (gerenderd indien PHP/Markdown), of lege string.
|
||||||
*/
|
*/
|
||||||
public function getFileContent(string $filePath): string
|
public function getFileContent(string $filePath): string
|
||||||
{
|
{
|
||||||
@@ -205,7 +388,12 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if file exists in content directory
|
* Controleer of een bestand in de contentdirectory bestaat.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $filename Bestandsnaam relatief ten opzichte van de contentdirectory.
|
||||||
|
* @return bool True indien het bestand bestaat.
|
||||||
*/
|
*/
|
||||||
public function contentFileExists(string $filename): bool
|
public function contentFileExists(string $filename): bool
|
||||||
{
|
{
|
||||||
@@ -214,10 +402,72 @@ class CMSAPI implements PluginAPIInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all pages with their metadata
|
* Haal alle content-items (mappen + bestanden) op als lijst met entry-arrays.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>> Lijst met entries ['path' => ..., 'title' => ..., 'type' => ...].
|
||||||
*/
|
*/
|
||||||
public function getAllPages(): array
|
public function getAllPages(): array
|
||||||
{
|
{
|
||||||
return $this->cms->getAllPageTitles();
|
return $this->cms->getAllPageTitles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de auteursmetadata van de huidige pagina op.
|
||||||
|
*
|
||||||
|
* Geeft author_name, author_email en created terug uit de pagina-frontmatter.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,string> Auteursmetadata met sleutels: author_name, author_email, created.
|
||||||
|
*/
|
||||||
|
public function getPageAuthor(): array
|
||||||
|
{
|
||||||
|
$page = $this->cms->getPage();
|
||||||
|
$metadata = $page['metadata'] ?? [];
|
||||||
|
return [
|
||||||
|
'author_name' => $metadata['author_name'] ?? '',
|
||||||
|
'author_email' => $metadata['author_email'] ?? '',
|
||||||
|
'created' => $metadata['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de vertalingen voor een plugin op in de front-end context.
|
||||||
|
*
|
||||||
|
* Content-plugins volgen de huidige content-taal. Fallback-keten
|
||||||
|
* (afgehandeld door PluginManager): aangevraagde taal ->
|
||||||
|
* default_language van de plugin -> lege array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Override-taal; standaard de huidige content-taal.
|
||||||
|
* @return array<string,string> Vertalingen als [key => value].
|
||||||
|
*/
|
||||||
|
public function getPluginTranslations(string $pluginName, ?string $lang = null): array
|
||||||
|
{
|
||||||
|
if ($this->pluginManager === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$lang = $lang ?? $this->cms->currentLanguage;
|
||||||
|
return $this->pluginManager->getPluginTranslations($pluginName, $lang, 'site');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertaal een enkele sleutel voor een plugin in de front-end context.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Vertaalsleutel.
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Override-taal; standaard de huidige content-taal.
|
||||||
|
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||||
|
*/
|
||||||
|
public function t(string $key, string $pluginName, ?string $lang = null): string
|
||||||
|
{
|
||||||
|
$t = $this->getPluginTranslations($pluginName, $lang);
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,89 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for plugin API objects.
|
* Interface voor plugin-API-objecten.
|
||||||
* Both CMSAPI (front-end) and AdminPluginAPI (admin) implement this.
|
*
|
||||||
|
* Zowel CMSAPI (front-end) als AdminPluginAPI (admin) implementeren deze interface.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
interface PluginAPIInterface
|
interface PluginAPIInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Haal een configuratiewaarde op via dot-notatie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Configuratie-sleutel in dot-notatie (bijv. 'language.default').
|
||||||
|
* @param mixed $default Standaardwaarde indien de sleutel niet bestaat.
|
||||||
|
* @return mixed De gevonden waarde of $default indien niet gevonden.
|
||||||
|
*/
|
||||||
public function getConfig(string $key, $default = null);
|
public function getConfig(string $key, $default = null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de project-rootdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de project-root.
|
||||||
|
*/
|
||||||
|
public function getProjectRoot(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal het pad naar de contentdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* Bij een relatieve configuratiewaarde wordt deze afgezet tegen de project-root.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de contentdirectory.
|
||||||
|
*/
|
||||||
|
public function getContentDir(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal het pad naar de pluginsdirectory op (absoluut, genormaliseerd).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Absoluut pad naar de pluginsdirectory.
|
||||||
|
*/
|
||||||
|
public function getPluginsDir(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de versie-informatie uit version.php op als associatieve array.
|
||||||
|
*
|
||||||
|
* Bevat altijd minimaal de sleutel 'version'.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,mixed> Versie-informatie met minimaal de sleutel 'version'.
|
||||||
|
*/
|
||||||
|
public function getVersionInfo(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de vertalingen voor een plugin op in de huidige context.
|
||||||
|
*
|
||||||
|
* System-plugins resolven tegen de admin-taal; content-plugins tegen de
|
||||||
|
* huidige content-taal. De implementatie regelt de fallback naar de
|
||||||
|
* default_language van de plugin.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Optionele override-taal.
|
||||||
|
* @return array<string,string> Vertalingen als [key => value].
|
||||||
|
*/
|
||||||
|
public function getPluginTranslations(string $pluginName, ?string $lang = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertaal een enkele sleutel voor een plugin in de huidige context.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $key Vertaalsleutel.
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string|null $lang Optionele override-taal.
|
||||||
|
* @return string Vertaalde string, of $key indien niet gevonden.
|
||||||
|
*/
|
||||||
|
public function t(string $key, string $pluginName, ?string $lang = null): string;
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,116 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beheert het laden en hooks-systeem van plugins.
|
||||||
|
*
|
||||||
|
* Laadt ingeschakelde plugins uit de pluginsdirectory, registreert automatisch
|
||||||
|
* action- en filter-hooks, en biedt methoden voor plugin-configuratie,
|
||||||
|
* vertalingen, admin-routes en sidebar-content.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class PluginManager
|
class PluginManager
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Geladen plugin-instanties, geïndexeerd op plugin-naam.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string,object>
|
||||||
|
*/
|
||||||
private array $plugins = [];
|
private array $plugins = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Het absolute pad naar de pluginsdirectory.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private string $pluginsPath;
|
private string $pluginsPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De geïnjecteerde plugin-API-instantie (CMSAPI of AdminPluginAPI).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginAPIInterface|null
|
||||||
|
*/
|
||||||
private $api = null;
|
private $api = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lijst met ingeschakelde plugin-namen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<int,string>
|
||||||
|
*/
|
||||||
private array $enabledPlugins = [];
|
private array $enabledPlugins = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geregistreerde action-hooks, gegroepeerd per hook-naam en prioriteit.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string,array<int,array<int,callable>>>
|
||||||
|
*/
|
||||||
private array $actions = [];
|
private array $actions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geregistreerde filter-hooks, gegroepeerd per hook-naam en prioriteit.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string,array<int,array<int,callable>>>
|
||||||
|
*/
|
||||||
private array $filters = [];
|
private array $filters = [];
|
||||||
|
|
||||||
public function __construct(string $pluginsPath, array $enabledPlugins = [])
|
/**
|
||||||
|
* De standaardtaal van de site, gebruikt als fallback voor plugins
|
||||||
|
* zonder eigen default_language in plugin.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $siteDefaultLanguage = 'nl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construeer een PluginManager en laadt direct de ingeschakelde plugins.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginsPath Absoluut pad naar de pluginsdirectory.
|
||||||
|
* @param array<int,string> $enabledPlugins Lijst met ingeschakelde plugin-namen.
|
||||||
|
* @param string $siteDefaultLanguage Standaardtaal van de site.
|
||||||
|
*/
|
||||||
|
public function __construct(string $pluginsPath, array $enabledPlugins = [], string $siteDefaultLanguage = 'nl')
|
||||||
{
|
{
|
||||||
$this->pluginsPath = $pluginsPath;
|
$this->pluginsPath = $pluginsPath;
|
||||||
$this->enabledPlugins = $enabledPlugins;
|
$this->enabledPlugins = $enabledPlugins;
|
||||||
|
$this->siteDefaultLanguage = $siteDefaultLanguage !== '' ? $siteDefaultLanguage : 'nl';
|
||||||
$this->loadPlugins();
|
$this->loadPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stel de standaardtaal van de site in.
|
||||||
|
*
|
||||||
|
* Wordt gebruikt als fallback wanneer een plugin geen eigen `default_language`
|
||||||
|
* declareert in plugin.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $lang Taalcode (bijv. 'nl', 'en').
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setSiteDefaultLanguage(string $lang): void
|
||||||
|
{
|
||||||
|
$this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stel de plugin-API in en geef deze door aan alle geladen plugins.
|
||||||
|
*
|
||||||
|
* Plugins die een setAPI()-methode implementeren, ontvangen de API-instantie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param PluginAPIInterface $api De plugin-API-instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function setAPI($api): void
|
public function setAPI($api): void
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
$this->api = $api;
|
||||||
@@ -27,6 +122,16 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laad alle ingeschakelde plugins uit de pluginsdirectory.
|
||||||
|
*
|
||||||
|
* Per plugin wordt het hoofdbestand (<naam>.php) geïncludeerd, de plugin-class
|
||||||
|
* geïnstantieerd, en action- en filter-hooks automatisch geregistreerd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function loadPlugins(): void
|
private function loadPlugins(): void
|
||||||
{
|
{
|
||||||
if (!is_dir($this->pluginsPath)) {
|
if (!is_dir($this->pluginsPath)) {
|
||||||
@@ -71,16 +176,45 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registreer een action-callback voor een hook.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $hook Naam van de action-hook.
|
||||||
|
* @param callable $callback De callback die bij de hook wordt uitgevoerd.
|
||||||
|
* @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function addAction(string $hook, callable $callback, int $priority = 10): void
|
public function addAction(string $hook, callable $callback, int $priority = 10): void
|
||||||
{
|
{
|
||||||
$this->actions[$hook][$priority][] = $callback;
|
$this->actions[$hook][$priority][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registreer een filter-callback voor een hook.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $hook Naam van de filter-hook.
|
||||||
|
* @param callable $callback De callback die de waarde filtert.
|
||||||
|
* @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function addFilter(string $hook, callable $callback, int $priority = 10): void
|
public function addFilter(string $hook, callable $callback, int $priority = 10): void
|
||||||
{
|
{
|
||||||
$this->filters[$hook][$priority][] = $callback;
|
$this->filters[$hook][$priority][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voer alle geregistreerde callbacks voor een action-hook uit.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $hook Naam van de action-hook.
|
||||||
|
* @param mixed ...$args Argumenten die aan elke callback worden doorgegeven.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function doAction(string $hook, ...$args): void
|
public function doAction(string $hook, ...$args): void
|
||||||
{
|
{
|
||||||
if (!isset($this->actions[$hook])) return;
|
if (!isset($this->actions[$hook])) return;
|
||||||
@@ -92,6 +226,16 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pas alle geregistreerde filter-callbacks toe op een waarde.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $hook Naam van de filter-hook.
|
||||||
|
* @param mixed $value De initiële waarde die wordt gefilterd.
|
||||||
|
* @param mixed ...$args Extra argumenten voor elke filter-callback.
|
||||||
|
* @return mixed De gefilterde waarde.
|
||||||
|
*/
|
||||||
public function applyFilters(string $hook, $value, ...$args)
|
public function applyFilters(string $hook, $value, ...$args)
|
||||||
{
|
{
|
||||||
if (!isset($this->filters[$hook])) return $value;
|
if (!isset($this->filters[$hook])) return $value;
|
||||||
@@ -104,26 +248,193 @@ class PluginManager
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal een specifieke plugin-instantie op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $name Plugin-naam (directorynaam).
|
||||||
|
* @return object|null De plugin-instantie of null indien niet geladen.
|
||||||
|
*/
|
||||||
public function getPlugin(string $name): ?object
|
public function getPlugin(string $name): ?object
|
||||||
{
|
{
|
||||||
return $this->plugins[$name] ?? null;
|
return $this->plugins[$name] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal alle geladen plugin-instanties op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string,object> Plugin-instanties geïndexeerd op naam.
|
||||||
|
*/
|
||||||
public function getAllPlugins(): array
|
public function getAllPlugins(): array
|
||||||
{
|
{
|
||||||
return $this->plugins;
|
return $this->plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de lijst met ingeschakelde plugin-namen op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,string> Lijst met ingeschakelde plugin-namen.
|
||||||
|
*/
|
||||||
public function getEnabledPlugins(): array
|
public function getEnabledPlugins(): array
|
||||||
{
|
{
|
||||||
return $this->enabledPlugins;
|
return $this->enabledPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleer of een plugin is ingeschakeld.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-naam (directorynaam).
|
||||||
|
* @return bool True indien de plugin is ingeschakeld.
|
||||||
|
*/
|
||||||
public function isEnabled(string $pluginName): bool
|
public function isEnabled(string $pluginName): bool
|
||||||
{
|
{
|
||||||
return in_array($pluginName, $this->enabledPlugins, true);
|
return in_array($pluginName, $this->enabledPlugins, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de runtime-configuratie van een plugin op.
|
||||||
|
*
|
||||||
|
* Standaardwaarden uit plugin.json `settings` worden samengevoegd met
|
||||||
|
* overrides uit het config.json van de plugin.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-naam (directorynaam).
|
||||||
|
* @return array<string,mixed> Opgeloste configuratie als [key => value].
|
||||||
|
*/
|
||||||
|
public function getPluginConfig(string $pluginName): array
|
||||||
|
{
|
||||||
|
$pluginDir = $this->pluginsPath . '/' . $pluginName;
|
||||||
|
$pluginJsonFile = $pluginDir . '/plugin.json';
|
||||||
|
$configJsonFile = $pluginDir . '/config.json';
|
||||||
|
|
||||||
|
$defaults = [];
|
||||||
|
if (file_exists($pluginJsonFile)) {
|
||||||
|
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
|
||||||
|
foreach ($pluginJson['settings'] ?? [] as $setting) {
|
||||||
|
if (isset($setting['key'])) {
|
||||||
|
$defaults[$setting['key']] = $setting['default'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = [];
|
||||||
|
if (file_exists($configJsonFile)) {
|
||||||
|
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($defaults, $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de standaard (fallback) taal van een plugin op.
|
||||||
|
*
|
||||||
|
* Resolved in volgorde van:
|
||||||
|
* 1. plugin.json `default_language`
|
||||||
|
* 2. CMS site-config `language.default`
|
||||||
|
* 3. 'nl'
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @return string Taalcode (bijv. 'nl', 'en').
|
||||||
|
*/
|
||||||
|
public function getPluginDefaultLanguage(string $pluginName): string
|
||||||
|
{
|
||||||
|
$pluginJsonFile = $this->pluginsPath . '/' . $pluginName . '/plugin.json';
|
||||||
|
if (file_exists($pluginJsonFile)) {
|
||||||
|
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
|
||||||
|
if (!empty($pluginJson['default_language'])) {
|
||||||
|
return (string)$pluginJson['default_language'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->siteDefaultLanguage ?? 'nl';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laad de vertalingen voor een plugin voor de aangevraagde taal.
|
||||||
|
*
|
||||||
|
* Fallback-keten: aangevraagde taal -> standaardtaal van de plugin -> lege array.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-directorynaam.
|
||||||
|
* @param string $lang Aangevraagde taalcode.
|
||||||
|
* @param string $context Vertaalcontext: 'admin' of 'site' (front-end).
|
||||||
|
* Standaard 'admin' voor system-plugins, 'site' voor content-plugins.
|
||||||
|
* @return array<string,string> Vertalingen als [key => value].
|
||||||
|
*/
|
||||||
|
public function getPluginTranslations(string $pluginName, string $lang, string $context = 'admin'): array
|
||||||
|
{
|
||||||
|
$langDir = $this->pluginsPath . '/' . $pluginName . '/language';
|
||||||
|
if (!is_dir($langDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try requested language first
|
||||||
|
$file = $langDir . '/' . $lang . '/' . $context . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$t = include $file;
|
||||||
|
if (is_array($t)) {
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the plugin's default language
|
||||||
|
$defaultLang = $this->getPluginDefaultLanguage($pluginName);
|
||||||
|
if ($defaultLang !== $lang) {
|
||||||
|
$fallbackFile = $langDir . '/' . $defaultLang . '/' . $context . '.php';
|
||||||
|
if (file_exists($fallbackFile)) {
|
||||||
|
$t = include $fallbackFile;
|
||||||
|
if (is_array($t)) {
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verzamel vertalingen voor alle geladen plugins voor de aangevraagde taal.
|
||||||
|
*
|
||||||
|
* Geeft een associatieve array terug, geïndexeerd op plugin-naam:
|
||||||
|
* ['Statistics' => [...], 'Logs' => [...], ...]
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $lang Aangevraagde taalcode.
|
||||||
|
* @param string $context 'admin' of 'site'.
|
||||||
|
* @return array<string,array<string,string>> Plugin-vertalingen geïndexeerd op plugin-naam.
|
||||||
|
*/
|
||||||
|
public function getAllPluginTranslations(string $lang, string $context = 'admin'): array
|
||||||
|
{
|
||||||
|
$all = [];
|
||||||
|
foreach ($this->plugins as $pluginName => $plugin) {
|
||||||
|
// System plugins follow the admin language; content plugins follow the content language.
|
||||||
|
// The caller decides the context; here we just load for every plugin.
|
||||||
|
$all[$pluginName] = $this->getPluginTranslations($pluginName, $lang, $context);
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleer of een plugin viewable is.
|
||||||
|
*
|
||||||
|
* Een plugin is viewable tenzij zijn configuratie 'viewable' expliciet op false zet.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param object $plugin De plugin-instantie.
|
||||||
|
* @return bool True indien de plugin viewable is.
|
||||||
|
*/
|
||||||
public function isPluginViewable(object $plugin): bool
|
public function isPluginViewable(object $plugin): bool
|
||||||
{
|
{
|
||||||
if (method_exists($plugin, 'getConfig')) {
|
if (method_exists($plugin, 'getConfig')) {
|
||||||
@@ -133,6 +444,17 @@ class PluginManager
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haal de samengevoegde sidebar-content van alle viewable plugins op.
|
||||||
|
*
|
||||||
|
* Elke plugin met een getSidebarContent()-methode levert HTML die wordt
|
||||||
|
* ingepakt in een Bootstrap-card met de plugin-titel.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array<int,string>|null $allowedPlugins Optionele whitelist van plugin-namen.
|
||||||
|
* @return string De samengevoegde sidebar-HTML.
|
||||||
|
*/
|
||||||
public function getSidebarContent(?array $allowedPlugins = null): string
|
public function getSidebarContent(?array $allowedPlugins = null): string
|
||||||
{
|
{
|
||||||
$sidebarContent = '';
|
$sidebarContent = '';
|
||||||
@@ -172,9 +494,11 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get CSS URLs from all enabled plugins that provide getCssUrl().
|
* Haal CSS-URL's op van alle ingeschakelde plugins die getCssUrl() implementeren.
|
||||||
*
|
*
|
||||||
* @return array List of CSS URLs
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,string> Lijst met CSS-URL's.
|
||||||
*/
|
*/
|
||||||
public function getPluginCssUrls(): array
|
public function getPluginCssUrls(): array
|
||||||
{
|
{
|
||||||
@@ -191,10 +515,13 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect admin menu items from all enabled plugins that implement getAdminMenu().
|
* Verzamel admin-menu-items van alle plugins die getAdminMenu() implementeren.
|
||||||
* Each plugin returns [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
|
|
||||||
*
|
*
|
||||||
* @return array Admin menu items from all plugins
|
* Elke plugin levert [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>> Admin-menu-items van alle plugins.
|
||||||
*/
|
*/
|
||||||
public function getAdminMenuItems(): array
|
public function getAdminMenuItems(): array
|
||||||
{
|
{
|
||||||
@@ -213,12 +540,16 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch an admin route to a plugin that handles it.
|
* Dispatch een admin-route naar de plugin die deze afhandelt.
|
||||||
* Plugins implement handleAdminRoute(string $action): ?string (returns rendered HTML or null).
|
|
||||||
*
|
*
|
||||||
* @param string $pluginName Plugin name
|
* Plugins implementeren handleAdminRoute(string $action): ?string
|
||||||
* @param string $action Action/sub-route within the plugin
|
* (geeft gerenderde HTML of null terug).
|
||||||
* @return string|null Rendered HTML, or null if plugin doesn't handle it
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $pluginName Plugin-naam.
|
||||||
|
* @param string $action Actie/sub-route binnen de plugin.
|
||||||
|
* @return string|null Gerenderde HTML, of null indien de plugin deze niet afhandelt.
|
||||||
*/
|
*/
|
||||||
public function dispatchAdminRoute(string $pluginName, string $action): ?string
|
public function dispatchAdminRoute(string $pluginName, string $action): ?string
|
||||||
{
|
{
|
||||||
@@ -233,10 +564,12 @@ class PluginManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find which plugin handles a given admin route.
|
* Bepaal welke plugin een opgegeven admin-route afhandelt.
|
||||||
*
|
*
|
||||||
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
|
* @since 2.6.5
|
||||||
* @return array|null ['plugin' => name, 'action' => action] or null
|
*
|
||||||
|
* @param string $route De admin-route (bijv. 'statistics' of 'statistics/details').
|
||||||
|
* @return array<string,mixed>|null ['plugin' => naam, 'action' => actie, 'permission' => vereiste permissie] of null.
|
||||||
*/
|
*/
|
||||||
public function resolveAdminRoute(string $route): ?array
|
public function resolveAdminRoute(string $route): ?array
|
||||||
{
|
{
|
||||||
@@ -248,6 +581,7 @@ class PluginManager
|
|||||||
return [
|
return [
|
||||||
'plugin' => $item['plugin'] ?? '',
|
'plugin' => $item['plugin'] ?? '',
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
|
'permission' => $item['permission'] ?? 'plugins',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* Router voor de PHP development-server: clean-URL support en statische bestanden.
|
||||||
|
*
|
||||||
|
* Wordt gebruikt via `php -S localhost:8080 cms/router.php`. Houdt de request-
|
||||||
|
* URI tegen de public/-map voor statische bestanden, serveert theme-, admin-
|
||||||
|
* theme- en plugin-assets buiten public/, en routeert /admin en taal-geprefixte
|
||||||
|
* paden (/nl, /en) door naar admin.php respectievelijk index.php.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @package CodePress
|
||||||
|
*/
|
||||||
// Router file for PHP development server - clean URL support + static file serving
|
// Router file for PHP development server - clean URL support + static file serving
|
||||||
|
|
||||||
$requestUri = $_SERVER['REQUEST_URI'];
|
$requestUri = $_SERVER['REQUEST_URI'];
|
||||||
@@ -19,6 +30,11 @@ $mimeTypes = [
|
|||||||
'json' => 'application/json',
|
'json' => 'application/json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statische bestanden uit public/ serveren met juiste MIME-type.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Serve static files from public/
|
// Serve static files from public/
|
||||||
$filePath = $publicDir . $path;
|
$filePath = $publicDir . $path;
|
||||||
if (is_file($filePath)) {
|
if (is_file($filePath)) {
|
||||||
@@ -30,6 +46,13 @@ if (is_file($filePath)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme-assets uit themes/<naam>/ serveren (bijv. /themes/default/js/theme.js).
|
||||||
|
*
|
||||||
|
* Path-traversal wordt afgedwongen via realpath() + prefix-controle.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Serve theme assets from the themes/ directory (e.g. /themes/default/js/theme.js)
|
// Serve theme assets from the themes/ directory (e.g. /themes/default/js/theme.js)
|
||||||
if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
||||||
$themeName = $m[1];
|
$themeName = $m[1];
|
||||||
@@ -50,6 +73,13 @@ if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-theme-assets serveren (bijv. /admin/assets/css/bootstrap.min.js).
|
||||||
|
*
|
||||||
|
* Bronmap: admin/theme/default/assets/.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Serve admin theme assets (e.g. /admin/assets/css/bootstrap.min.js)
|
// Serve admin theme assets (e.g. /admin/assets/css/bootstrap.min.js)
|
||||||
// Served from admin/theme/default/assets/
|
// Served from admin/theme/default/assets/
|
||||||
if (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
if (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
||||||
@@ -70,6 +100,11 @@ if (preg_match('#^/admin/assets/(.+)$#', $path, $m)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-assets serveren (bijv. /plugins/Navigation/assets/css/navigation.css).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Serve plugin assets (e.g. /plugins/Navigation/assets/css/navigation.css)
|
// Serve plugin assets (e.g. /plugins/Navigation/assets/css/navigation.css)
|
||||||
if (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
if (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
||||||
$pluginName = $m[1];
|
$pluginName = $m[1];
|
||||||
@@ -90,6 +125,13 @@ if (preg_match('#^/plugins/([^/]+)/assets/(.+)$#', $path, $m)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-routes doorsturen: /admin/<route> → admin.php?route=<route>.
|
||||||
|
*
|
||||||
|
* Default route is 'dashboard' indien geen subpad opgegeven.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Admin routes: /admin/login → admin.php?route=login
|
// Admin routes: /admin/login → admin.php?route=login
|
||||||
if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
||||||
$_GET['route'] = $m[1] ?? 'dashboard';
|
$_GET['route'] = $m[1] ?? 'dashboard';
|
||||||
@@ -97,6 +139,14 @@ if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taal-geprefixte routes: /nl/<page> of /en/<page> → index.php?lang=...&page=...
|
||||||
|
*
|
||||||
|
* /nl/guide activeert de guide-flag met bestaande ?page=. Default valt
|
||||||
|
* door naar index.php.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
// Language-prefixed routes: /nl/page/path → index.php?lang=nl&page=page/path
|
// Language-prefixed routes: /nl/page/path → index.php?lang=nl&page=page/path
|
||||||
if (preg_match('#^/(nl|en)(?:/(.+))?$#', $path, $m)) {
|
if (preg_match('#^/(nl|en)(?:/(.+))?$#', $path, $m)) {
|
||||||
$_GET['lang'] = $m[1];
|
$_GET['lang'] = $m[1];
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# CodePress CMS v2.6.1c — Release Notes
|
||||||
|
|
||||||
|
**Release datum:** 2026-08-17
|
||||||
|
**Codename:** Lyra
|
||||||
|
**Status:** stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nieuwe features
|
||||||
|
|
||||||
|
### Admin gebruikersbeheer volledig opnieuw ontworpen
|
||||||
|
|
||||||
|
De admin/gebruikers pagina is volledig heringericht met drie aparte pagina's:
|
||||||
|
|
||||||
|
#### Gebruikerslijst (`/admin/users`)
|
||||||
|
- Lijst met alle gebruikers (gebruikersnaam, rol, login e-mail, aanmaakdatum, acties)
|
||||||
|
- **Zoekveld** — zoekt op gebruikersnaam, e-mail of auteur naam
|
||||||
|
- **Rol filter** — dropdown om op een specifieke rol te filteren
|
||||||
|
- **"Nieuwe gebruiker" knop** — linkt naar het aanmaakformulier
|
||||||
|
- **Bewerk knop** per gebruiker — linkt naar de profiel pagina
|
||||||
|
- **Verwijder knop** per gebruiker (bevestiging via JavaScript)
|
||||||
|
|
||||||
|
#### Profiel bewerken (`/admin/users-edit?user=<naam>`)
|
||||||
|
- **Profiel gegevens**: login e-mail, auteur naam, auteur e-mail wijzigen
|
||||||
|
- **Wachtwoord wijzigen**: nieuw wachtwoord + bevestiging met JavaScript validatie
|
||||||
|
- **Rol wijzigen**: dropdown met alle rollen, toont huidige rol met gekleurde badge
|
||||||
|
- **Gevarenzone**: gebruiker verwijderen (alleen voor andere gebruikers, niet jezelf)
|
||||||
|
|
||||||
|
#### Nieuwe gebruiker (`/admin/users-new`)
|
||||||
|
- Formulier met gebruikersnaam, wachtwoord, e-mail, auteur naam, auteur e-mail, rol
|
||||||
|
- Na aanmaken → automatische redirect naar profiel pagina van de nieuwe gebruiker
|
||||||
|
|
||||||
|
### CLI commando voor admin wachtwoord reset
|
||||||
|
|
||||||
|
Nieuw CLI script `cli/reset-admin-password.php` om een admin wachtwoord te resetten en brute-force lockout te wissen — handig bij lockout of vergeten wachtwoord:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Met specifiek wachtwoord
|
||||||
|
php cli/reset-admin-password.php admin NieuwWachtwoord123
|
||||||
|
|
||||||
|
# Met automatisch gegenereerd wachtwoord
|
||||||
|
php cli/reset-admin-password.php admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Het commando wijzigt het wachtwoord (als bcrypt hash), wist de lockout en toont het nieuwe wachtwoord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbeteringen
|
||||||
|
|
||||||
|
### AdminAuth uitbreiding
|
||||||
|
- Nieuwe public methode `clearLockout(string $username)` — wist failed login attempts (voor CLI gebruik)
|
||||||
|
|
||||||
|
### Handleidingen bijgewerkt
|
||||||
|
- `guide/nl/admin-beheerder/gebruikers.md` — volledig herschreven met nieuwe architectuur (lijst, zoeken/filter, profiel bewerken, CLI reset)
|
||||||
|
- `guide/en/admin-beheerder/gebruikers.md` — Engelse versie bijgewerkt
|
||||||
|
|
||||||
|
### Vertalingen
|
||||||
|
- 15 nieuwe admin vertaalkeys toegevoegd in NL, EN en DE (`users_list`, `search_users`, `all_roles`, `filter`, `back_to_users`, `profile_info`, `change_password`, `new_password`, `confirm_password`, `passwords_no_match`, `danger_zone`, `delete_user`, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beveiliging
|
||||||
|
- Pentest suite: 30/30 tests geslaagd, 0 vulnerabilities
|
||||||
|
- Security headers aanwezig: X-Frame-Options, Content-Security-Policy, X-Content-Type-Options
|
||||||
|
- Geen vulnerabilities gedetecteerd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- WCAG 2.1 AA: 25/25 tests geslaagd (100%)
|
||||||
|
- ARIA landmarks aanwezig (24 per pagina)
|
||||||
|
- Semantic HTML structuur (header, nav, main, footer)
|
||||||
|
- Skip-to-content links, focus indicators, screen reader support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systeem vereisten
|
||||||
|
- PHP >= 8.0
|
||||||
|
- Extensies: json, mbstring
|
||||||
|
- Optioneel: opcache (aanbevolen voor performance)
|
||||||
|
- Git (optioneel, voor content versioning feature)
|
||||||
|
- ZipArchive (optioneel, voor ZIP backup/restore feature)
|
||||||
|
- Apache met `mod_rewrite` en `AllowOverride All` (voor clean URLs en asset-serving)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgraden
|
||||||
|
1. Pull de nieuwe code
|
||||||
|
2. Test de website
|
||||||
|
3. Het admin wachtwoord kan gereset worden via `php cli/reset-admin-password.php admin <wachtwoord>` indien nodig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test resultaten samenvatting
|
||||||
|
|
||||||
|
| Test | Resultaat |
|
||||||
|
|------|----------|
|
||||||
|
| Pentest | 30/30 geslaagd, 0 vulnerabilities |
|
||||||
|
| WCAG 2.1 AA Accessibility | 25/25 geslaagd, 100% compliance |
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# CodePress CMS v2.6.1d — Release Notes
|
||||||
|
|
||||||
|
**Release datum:** 2026-08-18
|
||||||
|
**Codename:** Lyra
|
||||||
|
**Status:** stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
Deze release richt zich volledig op **plugin-verbeteringen**: plugin internationalisatie (i18n), plugin uniformiteit, en een volledig vernieuwde plugin-editor met bestandsbrowser, uploaden, verwijderen en verplaatsen van bestanden binnen een plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nieuwe features
|
||||||
|
|
||||||
|
### 1. Plugin internationalisatie (i18n)
|
||||||
|
|
||||||
|
Plugins hebben nu hun eigen `language/` map met vertalingen, geïntegreerd met de admin-taal en content-taal.
|
||||||
|
|
||||||
|
- **Systeem plugins** (`type: "system"`) volgen de geselecteerde **admin taal** — vertalingen in `language/<lang>/admin.php`
|
||||||
|
- **Content plugins** (`type: "content"`) volgen de geselecteerde **content taal** — vertalingen in `language/<lang>/site.php`
|
||||||
|
- **Fallback chain**: geselecteerde taal → plugin `default_language` (uit `plugin.json`) → CMS site default → lege array (sleutel wordt ongewijzigd getoond)
|
||||||
|
|
||||||
|
**Core uitbreidingen:**
|
||||||
|
- `PluginManager`: `getPluginDefaultLanguage()`, `getPluginTranslations()`, `getAllPluginTranslations()` met fallback chain
|
||||||
|
- `PluginAPIInterface`: `getPluginTranslations()` + `t()` toegevoegd (contract voor beide API's)
|
||||||
|
- `AdminPluginAPI`: `getAdminLanguage()`, `getPluginTranslations()`, `t()` (systeem plugins)
|
||||||
|
- `CMSAPI`: `getPluginTranslations()`, `t()` (content plugins)
|
||||||
|
|
||||||
|
**Admin integratie:**
|
||||||
|
- `public/admin.php` laadt alle plugin-vertalingen als Twig global `ta_plugins` + functions `tap()` en `plugin_menu_label()`
|
||||||
|
- `handlePluginsConfig()` resolveert `label_key`/`help_key`/`option_label_key` via plugin-vertalingen voor instellingen-labels
|
||||||
|
- Admin sidebar toont vertaalde plugin-menu labels via `plugin_menu_label()`
|
||||||
|
|
||||||
|
**Alle 6 plugins bijgewerkt:**
|
||||||
|
- Dashboard, GeoIPInfo, Logs, Statistics (systeem): `language/nl/admin.php` + `language/en/admin.php`
|
||||||
|
- HTMLBlock, Navigation (content): `language/nl/site.php` + `language/en/site.php`
|
||||||
|
- `plugin.json`: `default_language: "nl"` toegevoegd
|
||||||
|
- Logs en Statistics: instellingen-labels via `label_key`/`help_key` in plaats van hardcoded tekst
|
||||||
|
- Alle hardcoded Nederlandse strings in `handleAdminRoute()`/`getSidebarContent()` vervangen door vertaalde waarden
|
||||||
|
|
||||||
|
### 2. Plugin uniformiteit
|
||||||
|
|
||||||
|
Elke plugin heeft nu een uniforme structuur:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<Plugin>/
|
||||||
|
├── <Plugin>.php # Hoofd plugin class
|
||||||
|
├── plugin.json # Metadata + instellingen
|
||||||
|
├── README.md # Plugin documentatie (uniform format)
|
||||||
|
├── assets/ # CSS/JS/SCSS (.gitkeep als leeg)
|
||||||
|
│ └── ...
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/
|
||||||
|
└── en/
|
||||||
|
```
|
||||||
|
|
||||||
|
- Per plugin een `README.md` met uniform format: plugin type, bestandsstructuur, functies, instellingen, taal support
|
||||||
|
- `plugins/README.md` herschreven als korte algemene intro met plugin-overzicht tabel
|
||||||
|
- `guide/nl|en/codepress-developer/plugin-development.md` volledig herschreven (structuur, plugin.json velden incl. `default_language`, settings schema met `label_key`/`help_key`, API methodes incl. `getPluginTranslations`/`t`, admin integratie, taal support sectie)
|
||||||
|
- `guide/nl|en/codepress-developer/architectuur.md` bijgewerkt met plugin `language/` map
|
||||||
|
|
||||||
|
### 3. Plugin editor volledig vernieuwd
|
||||||
|
|
||||||
|
De plugin-editor (`/admin/plugins-edit`) toonde alleen het `<Plugin>.php` bestand. Nu is het een volledige bestandsbeheer-omgeving.
|
||||||
|
|
||||||
|
#### Bestandsbrowser zijbalk
|
||||||
|
- Geneste, inklapbare boomstructuur van alle bestanden in de plugin-map (`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`) inclusief `language/` en `assets/` submappen
|
||||||
|
- Mappen die het actieve bestand bevatten worden automatisch uitgeklapt
|
||||||
|
- Iconen per bestandstype (PHP, JSON, MD, CSS, JS, HTML, mappen)
|
||||||
|
- Bootstrap collapse via `<button>` toggles met chevron-rotatie
|
||||||
|
- `scanPluginFiles()` helper — recursieve boom-opbouw met path-traversal bescherming
|
||||||
|
- `editor-toolbar.js` modeMap uitgebreid: `css: 'css'`, `scss: 'css'`, `js: 'javascript'`, `json: 'javascript'`
|
||||||
|
|
||||||
|
#### Nieuw bestand aanmaken
|
||||||
|
- "Nieuw bestand" knop + modal met pad-invoer (bijv. `helper.php` of `assets/css/extra.css`)
|
||||||
|
- Submappen worden automatisch aangemaakt
|
||||||
|
- Stub-content per extensie (`<?php\n` voor PHP, `{\n}\n` voor JSON)
|
||||||
|
- Path-traversal bescherming: `../`/`..\\`/`./` stripping + `..`/`.` segment-guard + realpath prefix-check
|
||||||
|
|
||||||
|
#### Uploaden
|
||||||
|
- Upload-knop + inklapbaar upload-formulier dat bestanden opslaat in `plugins/<naam>/assets/`
|
||||||
|
- Toegestane extensies: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD
|
||||||
|
- Nieuwe route: `plugins-file-upload`
|
||||||
|
- Protected plugins (Navigation) geblokkeerd
|
||||||
|
- Path-traversal bescherming
|
||||||
|
|
||||||
|
#### Verwijderen
|
||||||
|
- Per-bestand prullenbak-icoon in de bestandsboom (altijd zichtbaar, met mouseover-tekst)
|
||||||
|
- Nieuwe route: `plugins-file-delete`
|
||||||
|
- Path-traversal bescherming + protected plugins geblokkeerd + dotfiles geweigerd (`.gitkeep` kan niet verwijderd worden)
|
||||||
|
- Na verwijderen valt de editor terug op het hoofd-bestand
|
||||||
|
|
||||||
|
#### Verplaatsen
|
||||||
|
- Per-bestand verplaats-icoon (pijlen) in de bestandsboom
|
||||||
|
- Nieuwe route: `plugins-file-move` + `pages/plugins-move-form.twig`
|
||||||
|
- Dropdown met alle mappen binnen de plugin (plugin root als optie, huidige map uitgesloten)
|
||||||
|
- `rename()` uitvoering met path-traversal bescherming voor zowel bron als doel
|
||||||
|
- Protected plugins geblokkeerd
|
||||||
|
|
||||||
|
### 4. Media invoegen in editor
|
||||||
|
|
||||||
|
De media-knop in de editor-toolbar was al die tijd stuk — het probeerde een niet-bestaand `#mediaModal` te openen.
|
||||||
|
|
||||||
|
- Nieuw JSON-endpoint `/admin/media-list`: retourneert media-bestanden (recursief), images eerst
|
||||||
|
- Nieuwe herbruikbare Twig include `pages/_media-modal.twig`: Bootstrap modal met zoekfilter en raster van media-bestanden
|
||||||
|
- Laadt media-lijst via `fetch('/admin/media-list')` bij openen
|
||||||
|
- Bij klik wordt een snippet in de CodeMirror-editor ingevoegd op de cursor:
|
||||||
|
- Markdown: ``
|
||||||
|
- HTML/PHP: `<img src="url" alt="naam">` / `<a>` / `<video>` / `<audio>`
|
||||||
|
- CSS/SCSS/JS/JSON: ruwe URL
|
||||||
|
- **Plugin-context**: in de plugin-editor scant het endpoint `plugins/<naam>/assets/` i.p.v. content-map (via `?plugin=` parameter); URLs beginnen met `/plugins/<naam>/assets/`
|
||||||
|
- `admin.twig` includeert het modal wanneer `needs_editor` true is
|
||||||
|
- `editor-toolbar.js` media-case robuuster gemaakt
|
||||||
|
|
||||||
|
### 5. Plugin overzicht knoppen
|
||||||
|
|
||||||
|
De knoppen op de plugin-kaarten (`/admin/plugins`) tonen nu alleen iconen met mouseover-tekst (`title` + `aria-label`) in plaats van icoon + tekst, zodat ze netjes naast elkaar passen in de `btn-group w-100`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug fixes
|
||||||
|
|
||||||
|
- **Admin taal niet geïntegreerd met plugins** — opgelost via plugin i18n systeem (zie boven)
|
||||||
|
- **Plugin editor toonde alleen `<Plugin>.php`** — opgelost via bestandsbrowser (zie boven)
|
||||||
|
- **Media-knop in editor werkte niet** — `#mediaModal` bestond niet; opgelost via nieuwe media-modal include
|
||||||
|
- **Plugin editor uploaden/verwijderen/verplaatsen ontbrak** — toegevoegd (zie boven)
|
||||||
|
- **Plugin-kaart knoppen tekst liep niet goed** — teksten verwijderd, alleen iconen met title
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische details
|
||||||
|
|
||||||
|
### Nieuwe routes
|
||||||
|
- `media-list` — JSON endpoint voor media-bestanden
|
||||||
|
- `plugins-file-upload` — bestanden uploaden naar plugin assets/
|
||||||
|
- `plugins-file-delete` — bestand verwijderen uit plugin
|
||||||
|
- `plugins-file-move` — bestand verplaatsen binnen plugin
|
||||||
|
|
||||||
|
### Nieuwe bestanden
|
||||||
|
- `admin/theme/default/views/pages/_media-modal.twig` — herbruikbare media modal
|
||||||
|
- `admin/theme/default/views/pages/plugins-move-form.twig` — verplaats-pagina
|
||||||
|
- `plugins/<Plugin>/language/{nl,en}/{admin,site}.php` — plugin vertalingen (per plugin)
|
||||||
|
- `plugins/<Plugin>/README.md` — uniforme documentatie (per plugin)
|
||||||
|
- `plugins/<Plugin>/assets/.gitkeep` — lege assets map placeholder
|
||||||
|
|
||||||
|
### Gewijzigde bestanden (core)
|
||||||
|
- `cms/core/plugin/PluginManager.php` — i18n methoden, site default language
|
||||||
|
- `cms/core/plugin/PluginAPIInterface.php` — `getPluginTranslations()`, `t()` contract
|
||||||
|
- `cms/core/plugin/AdminPluginAPI.php` — admin language, plugin translations
|
||||||
|
- `cms/core/plugin/CMSAPI.php` — content language plugin translations
|
||||||
|
- `cms/core/class/CodePressCMS.php` — PluginManager + CMSAPI wiring
|
||||||
|
- `public/admin.php` — media-list endpoint, plugin-file routes, plugin translations Twig globals, handlePluginsEdit herschreven, handlePluginsConfig label_key support, flash messages
|
||||||
|
- `admin/theme/default/views/layouts/admin.twig` — media modal include
|
||||||
|
- `admin/theme/default/views/pages/plugins-edit.twig` — bestandsbrowser, upload, verwijderen, verplaatsen
|
||||||
|
- `admin/theme/default/views/pages/plugin-config.twig` — vertaalde instellingen-labels
|
||||||
|
- `admin/theme/default/views/pages/plugins.twig` — icoon-only knoppen met titles
|
||||||
|
- `admin/theme/default/assets/js/editor-toolbar.js` — modeMap uitgebreid, media-case robuust
|
||||||
|
- `language/{nl,en,de}/admin.php` — nieuwe vertaalstrings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Pentest**: 30/30 tests geslaagd — 0 vulnerabilities
|
||||||
|
- **WCAG 2.1 AA accessibility**: 25/25 tests geslaagd — 100% compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestanden gewijzigd sinds v2.6.1c
|
||||||
|
|
||||||
|
40 bestanden gewijzigd, 2559 insertions(+), 369 deletions(-)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# CodePress CMS v2.6.3 — Release Notes
|
||||||
|
|
||||||
|
**Release datum:** 2026-08-20
|
||||||
|
**Codename:** Lyra
|
||||||
|
**Status:** stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
Deze release richt zich op **content met meerdere bestandstypes** en een
|
||||||
|
**duidelijkere Content API**. Content bestanden die dezelfde naam delen maar een
|
||||||
|
ander type hebben (`test.md`, `test.php`, `test.html`) worden nu correct
|
||||||
|
afgehandeld: de frontend serveert per extensie het juiste bestand, en de admin
|
||||||
|
accepteert en toont ze allemaal. Daarnaast is de `getAllPages()` API herschreven
|
||||||
|
naar een rijkere array-structuur en zijn de verberg-prefixen voor mappen/bestanden
|
||||||
|
gewijzigd (`.` vervangt `_`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nieuwe features & wijzigingen
|
||||||
|
|
||||||
|
### 1. Content bestanden met dezelfde naam, ander type
|
||||||
|
|
||||||
|
Eerder kon een content map niet twee bestanden bevatten met dezelfde naam maar
|
||||||
|
een verschillend type (bijv. `test.md` + `test.php`). De frontend routeerde
|
||||||
|
alleen op naam zonder extensie, waardoor slechts één bestand geserveerd werd.
|
||||||
|
|
||||||
|
Nu:
|
||||||
|
|
||||||
|
- **URL met extensie** (`/nl/test.php`) serveert exact dat bestand.
|
||||||
|
- **URL zonder extensie** (`/nl/test`) valt terug op het eerste bestaande bestand
|
||||||
|
met prioriteit **md > php > html** (via de nieuwe `resolveContentByType()`
|
||||||
|
helper).
|
||||||
|
- De admin content editor accepteert bestanden met dezelfde naam (ander type);
|
||||||
|
de "bestaat al" check is per extensie. De melding is verduidelijkt naar
|
||||||
|
"Bestand met dit type bestaat al."
|
||||||
|
- De admin preview-knop linkt per extensie (`test.php` → `/nl/test.php`) zodat
|
||||||
|
elk type individueel te bekijken blijft.
|
||||||
|
|
||||||
|
### 2. Frontend navigatie tonen alle bestandstypes
|
||||||
|
|
||||||
|
`scanDirectory()` (menu), `getDirectoryListing()` (directory view) en
|
||||||
|
`searchInDirectory()` (zoeken) tonen nu elk bestand apart met de extensie
|
||||||
|
behouden in de URL. Bestanden met dezelfde naam maar ander type zijn niet meer
|
||||||
|
samengevoegd tot één menu-item — elk type krijgt zijn eigen link.
|
||||||
|
|
||||||
|
### 3. getAllPages() array structuur
|
||||||
|
|
||||||
|
`ContentAPI::getAllPages()` (en `CMSAPI::getAllPages()` voor plugins) gaf eerder
|
||||||
|
een associative array `['pad' => 'titel']`. Nu is het een **list van entry
|
||||||
|
arrays**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
['path' => 'test-map', 'title' => 'Test Map', 'type' => 'folder'],
|
||||||
|
['path' => 'test-map/test.md','title' => 'Nieuwe pagina', 'type' => 'md'],
|
||||||
|
['path' => 'test.md', 'title' => 'Test', 'type' => 'md'],
|
||||||
|
['path' => 'test.php', 'title' => 'Test.', 'type' => 'php'],
|
||||||
|
['path' => 'test.html', 'title' => 'Nieuwe pagina', 'type' => 'html'],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||||
|
- Mappen krijgen hun eigen entry zodat de boom-structuur zichtbaar blijft.
|
||||||
|
- Bestanden met dezelfde naam maar ander type botsen niet meer (de extensie
|
||||||
|
zit in het pad).
|
||||||
|
|
||||||
|
`autoLinkPageTitles()` is aangepast aan de nieuwe structuur en slaat mappen over.
|
||||||
|
|
||||||
|
### 4. Verberg-prefix logica: `.` i.p.v. `_`
|
||||||
|
|
||||||
|
Vooraf verborg de frontend mappen/bestanden die begonnen met `.`, `-` of `_`.
|
||||||
|
Nu is `_` **geen** verberg-prefix meer — alleen `.` (en `-`) verbergen aan de
|
||||||
|
frontend. Dit maakt `.map` of `.bak` de standaard manier om content te verbergen.
|
||||||
|
|
||||||
|
De admin daarentegen toont **wél** alle `.` bestanden en mappen (`.bak`,
|
||||||
|
`.map`), behalve `.git`/`.gitkeep`. De `scanEditorFilesNode()` functie heeft een
|
||||||
|
nieuwe `$scope` parameter die bepaalt of dotfiles getoond worden (alleen voor
|
||||||
|
`content` scope).
|
||||||
|
|
||||||
|
`collectContentPages()` (admin config default-page dropdown) toont nu ook `.`
|
||||||
|
bestanden.
|
||||||
|
|
||||||
|
### 5. ContentAPI getPage()/pageExists() met extensie
|
||||||
|
|
||||||
|
`ContentAPI::getPage('test.php')` en `pageExists('test.php')` respecteren nu de
|
||||||
|
expliciete extensie — ze openen dat specifieke bestand in plaats van terug te
|
||||||
|
vallen op md-prioriteit.
|
||||||
|
|
||||||
|
### 6. Handleiding content-api.md herschreven
|
||||||
|
|
||||||
|
De handleiding `guide/nl/content-beheerder/content-api.md` en de Engelse
|
||||||
|
tegenhanger zijn volledig herschreven:
|
||||||
|
|
||||||
|
- **Hoe content naar Twig gaat**: echo/print vs return mechanisme duidelijk
|
||||||
|
uitgelegd met voorbeelden
|
||||||
|
- **Wat je niet kunt**: geen eigen Twig variabelen vanuit PHP; je voedt alleen
|
||||||
|
`{{ content }}`
|
||||||
|
- **Beschikbare variabelen** in een `.php` bestand: `$api` en `$pageMetadata`
|
||||||
|
- **Nieuwe getAllPages() structuur** met type-veld en voorbeelden
|
||||||
|
- **Layout kiezen**: hoe de `layout` frontmatter key koppelt naar `theme.json`
|
||||||
|
- **Veiligheidsrichtlijnen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische details
|
||||||
|
|
||||||
|
### Nieuwe/gewijzigde functies (core)
|
||||||
|
|
||||||
|
- `CodePressCMS::resolveContentByType(string $basePath, ?string $preferredExt = null): ?array`
|
||||||
|
— Nieuwe helper die het eerste bestaande bestand (md > php > html) serveert,
|
||||||
|
of het bestand met de gevraagde extensie als `$preferredExt` opgegeven is.
|
||||||
|
- `CodePressCMS::scanForPageTitles()` — herschreven naar list van
|
||||||
|
`['path','title','type']` entries; mappen als eigen entry; `_` niet meer
|
||||||
|
verborgen.
|
||||||
|
- `CodePressCMS::scanDirectory()` / `searchInDirectory()` / `getDirectoryListing()`
|
||||||
|
— `_` verwijderd uit verberg-logica; extensie behouden in URLs.
|
||||||
|
- `CodePressCMS::autoLinkPageTitles()` — aangepast aan nieuwe array structuur.
|
||||||
|
- `ContentAPI::getPage()` / `pageExists()` — respecteren expliciete extensie.
|
||||||
|
- `ContentAPI::getAllPages()` / `CMSAPI::getAllPages()` — docblocks bijgewerkt.
|
||||||
|
- `scanEditorFilesNode()` — nieuwe `$scope` parameter; content-scope toont
|
||||||
|
dotfiles (behalve .git/.gitkeep).
|
||||||
|
- `scanContentDir()` / `collectContentPages()` — tonen nu `.` bestanden/mappen.
|
||||||
|
|
||||||
|
### Gewijzigde bestanden
|
||||||
|
|
||||||
|
- `cms/core/class/CodePressCMS.php` — resolveContentByType, scanForPageTitles,
|
||||||
|
scanDirectory, searchInDirectory, getDirectoryListing, autoLinkPageTitles,
|
||||||
|
sanitizePageParam (punt toegestaan), getPage (explicitExt terug)
|
||||||
|
- `cms/core/class/ContentAPI.php` — getPage/pageExists met extensie, getAllPages docblock
|
||||||
|
- `cms/core/plugin/CMSAPI.php` — getAllPages docblock
|
||||||
|
- `public/admin.php` — scanContentDir, scanEditorFilesNode, collectContentPages,
|
||||||
|
handleContentNew/handleContentFiles meldingen
|
||||||
|
- `admin/theme/default/views/pages/content-files.twig` — preview link per extensie
|
||||||
|
- `admin/theme/default/views/pages/content-edit.twig` — preview link per extensie
|
||||||
|
- `guide/nl/content-beheerder/content-api.md` — herschreven
|
||||||
|
- `guide/en/content-beheerder/content-api.md` — herschreven
|
||||||
|
- `version.php` — 2.6.3
|
||||||
|
- `README.md` / `README.en.md` — versie 2.6.3
|
||||||
|
- `TODO.md` — v2.6.3 sectie toegevoegd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- PHP lint: alle bestanden syntactisch correct (`php -l`)
|
||||||
|
- Live getest via Apache (development.codepress.noorlander.info):
|
||||||
|
- `/nl/test.php` serveert test.php
|
||||||
|
- `/nl/test.html` serveert test.html
|
||||||
|
- `/nl/test` (zonder ext) serveert test.md (md prioriteit)
|
||||||
|
- Admin content boom toont `.bak`, `.map`, `_images` en alle bestandstypes
|
||||||
|
- Frontend navigatie verbergt `.bak`/`.map`, toont alle bestandstypes apart
|
||||||
|
- `$api->getAllPages()` in test.php toont pad/titel/type structuur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volgende stappen
|
||||||
|
|
||||||
|
Zie `TODO.md` voor openstaand werk (multidomein implementatie).
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# CodePress CMS v2.6.5 — Release Notes
|
||||||
|
|
||||||
|
**Release datum:** 2026-08-27
|
||||||
|
**Codename:** Lyra
|
||||||
|
**Status:** stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
Deze release richt zich op **dynamische pad-resolutie**, **volledige
|
||||||
|
code-documentatie** en een **belangrijke security-fix** rond het standaard
|
||||||
|
admin-wachtwoord. Daarnaast is de git-geschiedenis schoongemaakt van
|
||||||
|
kraakbare wachtwoord-hashes die per ongeluk gecommit waren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nieuwe features & wijzigingen
|
||||||
|
|
||||||
|
### 1. Dynamische pad-resolutie via de Plugin-API
|
||||||
|
|
||||||
|
Eerder gaf `AdminPluginAPI::getContentDir()` een relatief pad ("content")
|
||||||
|
terug zonder dat te normaliseren tegen de project-root. Omdat Apache
|
||||||
|
`admin.php` uit de `public/` map draait, resolvedde dat relatieve pad naar
|
||||||
|
`public/content` (niet-bestaand) → het dashboard toonde 0 pagina's, 0
|
||||||
|
mappen en 0 B aan content.
|
||||||
|
|
||||||
|
Nu:
|
||||||
|
|
||||||
|
- `PluginAPIInterface` definieert universele pad-methodes
|
||||||
|
(`getProjectRoot()`, `getContentDir()`, `getPluginsDir()`,
|
||||||
|
`getVersionInfo()`) die door zowel `CMSAPI` (frontend) als
|
||||||
|
`AdminPluginAPI` (admin) worden geïmplementeerd.
|
||||||
|
- `AdminPluginAPI::getContentDir()` normaliseert relatieve paden tegen de
|
||||||
|
project-root (met Windows-drive-letter detectie), conform
|
||||||
|
`cms/core/config.php`.
|
||||||
|
- `CMSAPI` heeft deze methodes erbij gekregen (deze ontbraken op de
|
||||||
|
frontend-API).
|
||||||
|
- `public/index.php` media-/assets-serving gebruikt
|
||||||
|
`$config['content_dir']` i.p.v. een hardcoded `/content` pad, zodat een
|
||||||
|
aangepaste `content_dir` in `config.json` wordt gevolgd.
|
||||||
|
- De `Navigation` plugin haalt de content-root via
|
||||||
|
`$this->api->getContentDir()` i.p.v. hardcoded `dirname(__DIR__, 2)`;
|
||||||
|
de guide-root via `getProjectRoot()`.
|
||||||
|
- De `Logs` plugin haalt de log-dir via `$this->api->getProjectRoot()`
|
||||||
|
i.p.v. hardcoded `dirname(__DIR__, 2)`.
|
||||||
|
|
||||||
|
### 2. WordPress-stijl code-documentatie
|
||||||
|
|
||||||
|
Alle classes, methods, properties en functies in de codebase zijn
|
||||||
|
voorzien van volledige docblocks in WordPress-stijl (~450 docblocks):
|
||||||
|
|
||||||
|
```
|
||||||
|
/**
|
||||||
|
* Summary.
|
||||||
|
*
|
||||||
|
* Description.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param type $var Description.
|
||||||
|
* @param array $args {
|
||||||
|
* Optional. An array of arguments.
|
||||||
|
*
|
||||||
|
* @type type $key Description. Default 'value'. Accepts 'value', 'value'.
|
||||||
|
* @type type $key Description.
|
||||||
|
* }
|
||||||
|
* @param type $var Description.
|
||||||
|
* @return type Description.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Behandelde bestanden: `AdminAuth`, `Cache`, `RateLimiter`, `RequestLogger`,
|
||||||
|
`Analytics`, `BotGuard`, `ContentBackup`, `GeoIP`, `Logger`, `LogManager`,
|
||||||
|
`ThemeManager`, `CodePressCMS`, `PluginAPIInterface`, `CMSAPI`,
|
||||||
|
`AdminPluginAPI`, `PluginManager`, alle 6 plugins, `public/admin.php`
|
||||||
|
(82 functies), en de entrypoints/CLI-bestanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security fixes
|
||||||
|
|
||||||
|
### 3. Hardcoded plaintext-wachtwoord verwijderd
|
||||||
|
|
||||||
|
In `admin/src/AdminAuth.php` stond het standaard-wachtwoord letterlijk in
|
||||||
|
de broncode:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
|
||||||
|
```
|
||||||
|
|
||||||
|
Deze fallback draaide bij een nieuwe installatie wanneer zowel
|
||||||
|
`admin.json` als `admin.json.example` ontbraken. Het wachtwoord `'admin'`
|
||||||
|
was hierdoor in de gecommitte broncode zichtbaar.
|
||||||
|
|
||||||
|
Nu:
|
||||||
|
|
||||||
|
- Bij eerste installatie wordt een **cryptografisch veilig willekeurig
|
||||||
|
wachtwoord** gegenereerd (`random_bytes`, 16 alfanumerieke tekens).
|
||||||
|
- De bcrypt-hash wordt weggeschreven naar `admin/config/admin.json`.
|
||||||
|
- Het plaintext wachtwoord wordt **eenmalig** op het inlogscherm getoond
|
||||||
|
via een waarschuwing, zodat de beheerder het kan noteren.
|
||||||
|
- Na de eerste succesvolle login is het wachtwoord nergens meer in
|
||||||
|
plaintext opgeslagen.
|
||||||
|
|
||||||
|
Nieuwe methodes in `AdminAuth`:
|
||||||
|
|
||||||
|
- `generateRandomPassword(int $length = 16): string` — CSPRNG-gebaseerd.
|
||||||
|
- `getGeneratedPassword(): string` — geeft het gegenereerde wachtwoord
|
||||||
|
terug voor weergave op de login-pagina.
|
||||||
|
|
||||||
|
### 4. Git-geschiedenis schoongemaakt
|
||||||
|
|
||||||
|
Bij eerdere commits (`d97c67c`, `b8d4a4e`, `5884877`) was
|
||||||
|
`admin/config/admin.json` per ongeluk gecommit voordat het bestand
|
||||||
|
gitignored werd. Dit bestand bevatte een bcrypt-hash die kraakt naar het
|
||||||
|
wachtwoord `'admin'`. Ook `admin/config/admin.json.example` (gecommit)
|
||||||
|
en het oude `admin-console/config/admin.json` pad bevatten dergelijke
|
||||||
|
hashes.
|
||||||
|
|
||||||
|
Uitgevoerd:
|
||||||
|
|
||||||
|
- `git filter-branch` om `admin/config/admin.json`,
|
||||||
|
`admin/config/admin.json.example` en `admin-console/config/admin.json`
|
||||||
|
te verwijderen uit **alle commits** op alle branches en tags.
|
||||||
|
- `reflog expire --expire=now --all` + `gc --prune=now --aggressive` om
|
||||||
|
oude objecten te verwijderen.
|
||||||
|
- Verificatie: beide bekende hashes (`nUpoaCNZZ...`, `CV2uORTI...`) en
|
||||||
|
`password_hash('admin'` komen 0× voor in de hele object-database.
|
||||||
|
|
||||||
|
### 5. admin.json.example verwijderd
|
||||||
|
|
||||||
|
Het gecommitte `admin/config/admin.json.example` bevatte een geldige
|
||||||
|
bcrypt-hash (kraakt naar `'admin'`). Omdat de `AdminAuth`-fallback nu
|
||||||
|
zelf een veilig wachtwoord genereert, is het example-bestand overbodig
|
||||||
|
geworden en verwijderd uit de repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bijgewerkte documentatie
|
||||||
|
|
||||||
|
- `README.md` en `README.en.md`: installatie-stappen herschreven (geen
|
||||||
|
`admin.json.example` kopie-stap meer; verwijzing naar
|
||||||
|
`admin`/`admin` inlog verwijderd; uitleg over automatisch gegenereerd
|
||||||
|
wachtwoord bij eerste installatie).
|
||||||
|
- `AGENTS.md`: admin-inlog beschrijving bijgewerkt.
|
||||||
|
- `TODO.md`: v2.6.5 entry toegevoegd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onder de motorkap
|
||||||
|
|
||||||
|
| Bestand | Wijziging |
|
||||||
|
|---|---|
|
||||||
|
| `cms/core/plugin/PluginAPIInterface.php` | Universele pad-methodes toegevoegd |
|
||||||
|
| `cms/core/plugin/CMSAPI.php` | `getProjectRoot`/`getContentDir`/`getPluginsDir`/`getVersionInfo` toegevoegd |
|
||||||
|
| `cms/core/plugin/AdminPluginAPI.php` | `getContentDir` normaliseert relatieve paden |
|
||||||
|
| `public/index.php` | Media-serving via `$config['content_dir']` |
|
||||||
|
| `plugins/Navigation/Navigation.php` | Content/guide-root via de API |
|
||||||
|
| `plugins/Logs/Logs.php` | Log-dir via de API |
|
||||||
|
| `admin/src/AdminAuth.php` | Random-wachtwoord-generator + `getGeneratedPassword` |
|
||||||
|
| `admin/theme/default/views/login.twig` | Waarschuwing bij eerste installatie |
|
||||||
|
| `version.php` | 2.6.4 → 2.6.5 |
|
||||||
|
| `README.md`, `README.en.md`, `AGENTS.md`, `TODO.md` | Docs bijgewerkt |
|
||||||
|
| ~20 PHP-bestanden | WordPress-stijl docblocks toegevoegd |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests uitgevoerd
|
||||||
|
|
||||||
|
- `php -l` over alle 67 PHP-bestanden (zonder vendor): 0 syntax-errors.
|
||||||
|
- Apache smoke-test: login (nieuw wachtwoord), dashboard (4 pagina's /
|
||||||
|
2 mappen / 282 KB), content, users, plugins, logs, guide, homepage,
|
||||||
|
pagina en media-serving allen HTTP 200.
|
||||||
|
- Inloggen met oud wachtwoord `admin` werkt niet meer.
|
||||||
|
- Eerste-installatie flow getest: bij afwezigheid van `admin.json` wordt
|
||||||
|
een nieuw wachtwoord gegenereerd en op het inlogscherm getoond.
|
||||||
|
- Git-historie: beide oude hashes en `password_hash('admin'` 0×
|
||||||
|
aanwezig in `git log --all -p`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade-instructies
|
||||||
|
|
||||||
|
1. Pull de nieuwe versie.
|
||||||
|
2. Als je nog met het standaard-wachtwoord `admin` inlogt: wijzig het
|
||||||
|
direct na upgrade via de admin-interface of
|
||||||
|
`php cli/reset-admin-password.php admin <nieuw-wachtwoord>`.
|
||||||
|
3. optioneel: verwijder `admin/config/admin.json.example` uit je working
|
||||||
|
tree (is niet meer nodig).
|
||||||
@@ -1,32 +1,125 @@
|
|||||||
# Content management
|
# Content management
|
||||||
|
|
||||||
## Managing files
|
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.
|
||||||
|
|
||||||
- **New folder** — Create folder structure
|
## Content editor
|
||||||
- **New file** — Create a page (`.md`, `.php`, `.html`)
|
|
||||||
- **Edit** — Modify existing content in the CodeMirror editor
|
|
||||||
- **Rename** — Change file or folder names
|
|
||||||
- **Move** — Move content to another folder
|
|
||||||
- **Delete** — Remove content
|
|
||||||
- **Rename folder** — Change a folder name
|
|
||||||
|
|
||||||
## Editor (content-edit)
|
### File browser sidebar
|
||||||
|
- Shows the nested file tree of `content/`, with a clickable **content** root at the top
|
||||||
|
- Hidden directories (`.bak`, `.git`) and dotfiles are skipped
|
||||||
|
- Click a **file** to open it in the CodeMirror editor
|
||||||
|
- Click a **folder** (the folder name) to select it — the **folder detail pane** appears on the right
|
||||||
|
- The chevron (▶/▼) next to a folder expands/collapses it without selecting the folder
|
||||||
|
- Folders containing the active file are auto-expanded
|
||||||
|
- Per folder there are action buttons: new file (plus icon), new folder (folder-plus icon) — they operate on the selected folder
|
||||||
|
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||||
|
|
||||||
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML)
|
### Drag-and-drop
|
||||||
- **Toolbar** for quickly inserting Markdown
|
- Drag a file or folder onto another folder to move it
|
||||||
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
|
- Valid drop targets are highlighted during dragging
|
||||||
|
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||||
|
- Security: a folder cannot be dropped into itself or a subfolder
|
||||||
|
- Path-traversal protection: all paths stay within the content directory
|
||||||
|
|
||||||
## Layout selection
|
### Editable file types
|
||||||
|
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
|
||||||
|
|
||||||
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.
|
All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
|
||||||
|
|
||||||
## Plugins on pages
|
#### PHP content files
|
||||||
|
PHP files can deliver content in two ways:
|
||||||
|
1. **Echo** (output buffering): everything `echo`ed or outside `<?php` tags is used as content
|
||||||
|
2. **Return** (callback style): `return '<h1>Hello</h1>';` — the returned string is used as content
|
||||||
|
|
||||||
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection
|
In PHP files, the `ContentAPI` is available via `$api`:
|
||||||
- Choose which plugins appear in the sidebar
|
```php
|
||||||
- **Order is adjustable** with up/down buttons
|
<?php
|
||||||
- The plugin order is stored in the frontmatter `plugins:` key
|
$menu = $api->getMenu();
|
||||||
- Plugins are **hidden** when the chosen layout has no sidebar (e.g. `full_content`)
|
$pages = $api->getAllPages();
|
||||||
|
$config = $api->getConfig('site_title');
|
||||||
|
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend URLs and file names
|
||||||
|
The CMS searches files in order: `.md` → `.php` → `.html`. If files share the same name (e.g. `test.md` and `test.php`), `/nl/test` always opens `test.md`. To open a specific file type, add the extension to the URL:
|
||||||
|
- `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
|
||||||
|
- `/nl/test.php` → opens `test.php` (explicit extension)
|
||||||
|
- `/nl/test.html` → opens `test.html` (explicit extension)
|
||||||
|
|
||||||
|
The preview link in the editor automatically uses the extension for non-`.md` files.
|
||||||
|
|
||||||
|
### Set image size (Markdown)
|
||||||
|
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
|
||||||
|
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||||
|
- Click **New file** (in the folder-detail pane or at the top)
|
||||||
|
- 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
|
||||||
|
- The new file is created in the selected folder
|
||||||
|
|
||||||
|
### Upload a file
|
||||||
|
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||||
|
- Click **Upload** (in the folder-detail pane or at the top) to upload files to the selected folder
|
||||||
|
- Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
||||||
|
- Path-traversal protection: target dir must stay within `content/`
|
||||||
|
- After the upload you return to the selected folder
|
||||||
|
|
||||||
|
### Rename / delete a file
|
||||||
|
- Open the file in the editor (click the file name in the sidebar)
|
||||||
|
- **Rename**: edit the **Filename** field on the right above the editor and click Save — the file is renamed immediately
|
||||||
|
- **Delete**: click the delete button (trash) next to the file in the sidebar, with confirmation
|
||||||
|
|
||||||
|
### Folder management
|
||||||
|
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||||
|
- **Rename folder**: edit the **Folder name** field in the folder-detail pane and click Save
|
||||||
|
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane — they operate on the selected folder
|
||||||
|
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||||
|
- The content root is also selectable (click "content" at the top of the tree) — shows the folder-detail pane for the root (rename disabled)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@@ -35,9 +128,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
plugins:
|
created: 2026-08-19 10:30:25
|
||||||
- HTMLBlock
|
edited: 2026-08-20 14:22:01
|
||||||
- Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Page title
|
# Page title
|
||||||
|
|||||||
@@ -15,23 +15,63 @@ CodePress has four roles, defined in `AdminAuth::ROLE_PERMISSIONS`:
|
|||||||
|
|
||||||
Roles are displayed with their label via `AdminAuth::ROLE_LABELS`.
|
Roles are displayed with their label via `AdminAuth::ROLE_LABELS`.
|
||||||
|
|
||||||
## Adding a user
|
## Users list (`/admin/users`)
|
||||||
|
|
||||||
1. Go to **Users**
|
The users list shows all users with their username, role, login email and creation date.
|
||||||
2. Enter username
|
|
||||||
3. Choose password (stored as bcrypt hash)
|
|
||||||
4. Select a role
|
|
||||||
5. Click **Add**
|
|
||||||
|
|
||||||
## Editing a user
|
### Search and filter
|
||||||
|
|
||||||
- Change password (new bcrypt hash)
|
- **Search field**: search by username, email or author name
|
||||||
- Change role (immediately affects visible routes and sidebar items)
|
- **Role filter**: filter by a specific role via the dropdown
|
||||||
|
- Click **Filter** to apply the results
|
||||||
|
|
||||||
## Deleting a user
|
### Adding a new user
|
||||||
|
|
||||||
- Not possible for own account
|
1. Click **New user** (top right of the list)
|
||||||
- Confirm with password
|
2. Enter username, password (minimum 8 characters), email, author name and author email
|
||||||
|
3. Select a role
|
||||||
|
4. Click **Add user**
|
||||||
|
5. You will be automatically redirected to the profile page of the new user
|
||||||
|
|
||||||
|
## Editing a profile (`/admin/users-edit?user=<name>`)
|
||||||
|
|
||||||
|
Click on a user in the list to edit their profile. The profile page contains three sections:
|
||||||
|
|
||||||
|
### Profile information
|
||||||
|
- Login email, author name and author email can be changed
|
||||||
|
- The username cannot be changed
|
||||||
|
|
||||||
|
### Change password
|
||||||
|
- Enter a new password (minimum 8 characters)
|
||||||
|
- Confirm the password
|
||||||
|
- The password is stored as a bcrypt hash
|
||||||
|
|
||||||
|
### Change role
|
||||||
|
- Shows the current role with a colored badge
|
||||||
|
- Select a new role from the dropdown
|
||||||
|
- The change immediately affects the visible admin routes and sidebar items
|
||||||
|
|
||||||
|
### Delete user (Danger zone)
|
||||||
|
- Only visible for other users (not for your own account)
|
||||||
|
- Confirmation via JavaScript dialog
|
||||||
|
- After deletion you return to the users list
|
||||||
|
|
||||||
|
## Admin password reset via CLI
|
||||||
|
|
||||||
|
If the admin is locked out (e.g. due to brute-force lockout or forgotten password), the password can be reset via the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset with a specific password
|
||||||
|
php cli/reset-admin-password.php admin NewPassword123
|
||||||
|
|
||||||
|
# Reset with an automatically generated password
|
||||||
|
php cli/reset-admin-password.php admin
|
||||||
|
```
|
||||||
|
|
||||||
|
The command:
|
||||||
|
- Changes the password (as a bcrypt hash)
|
||||||
|
- Clears the brute-force lockout for the user
|
||||||
|
- Displays the new password in the terminal
|
||||||
|
|
||||||
## Access control
|
## Access control
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,44 @@ Only active plugins are shown in the admin sidebar.
|
|||||||
4. Enable the plugin
|
4. Enable the plugin
|
||||||
|
|
||||||
New plugins are content-plugins by default. To create a system plugin, add `"type": "system"` to `plugin.json`.
|
New plugins are content-plugins by default. To create a system plugin, add `"type": "system"` to `plugin.json`.
|
||||||
|
|
||||||
|
## Plugin editor
|
||||||
|
|
||||||
|
Via **Edit** (pencil icon) in the plugin overview you open the plugin editor (`/admin/plugins-edit?plugin=<name>`). It works the same as the content and theme editors.
|
||||||
|
|
||||||
|
### File browser sidebar
|
||||||
|
- Shows the nested file tree of the plugin, with a clickable **plugin** root at the top
|
||||||
|
- Click a file to open it in the CodeMirror editor
|
||||||
|
- Click a folder to open the **folder detail pane** on the right (new file, new folder, upload, delete)
|
||||||
|
- Folders containing the active file are auto-expanded
|
||||||
|
- Per folder there are action buttons: new file, new folder — they operate on the selected folder
|
||||||
|
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||||
|
|
||||||
|
### Drag-and-drop
|
||||||
|
- Drag a file or folder onto another folder to move it
|
||||||
|
- Valid drop targets are highlighted during dragging
|
||||||
|
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||||
|
- Protected plugins (like Navigation) cannot be edited/moved
|
||||||
|
|
||||||
|
### Editable file types
|
||||||
|
`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`
|
||||||
|
|
||||||
|
### Create a new file / folder
|
||||||
|
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||||
|
- Click **New file** or **New folder** (in the folder-detail pane or at the top)
|
||||||
|
- For a new file: enter a path within the plugin (e.g. `helper.php` or `assets/css/extra.css`)
|
||||||
|
- Subfolders are created automatically
|
||||||
|
|
||||||
|
### Move / delete a file
|
||||||
|
- **Move**: drag the file onto another folder (drag-and-drop), or click the pencil icon
|
||||||
|
- **Delete**: click the trash button next to the file in the sidebar, with confirmation
|
||||||
|
|
||||||
|
### Folder management
|
||||||
|
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||||
|
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane
|
||||||
|
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Protected plugins (Navigation) cannot be edited
|
||||||
|
- All actions require a CSRF token
|
||||||
|
- Path-traversal protection via `realpath()` + prefix check on the plugin dir
|
||||||
@@ -3,15 +3,87 @@
|
|||||||
## Managing themes
|
## Managing themes
|
||||||
|
|
||||||
1. Go to **Theme** in the admin menu
|
1. Go to **Theme** in the admin menu
|
||||||
2. **Activate** — Choose the active theme (stored in `config.json`)
|
2. **Edit** — Click the pencil icon to edit theme files (see [Theme editor](#theme-editor))
|
||||||
3. **Compile SCSS** — Process SCSS to CSS (forced)
|
3. **Activate** — Click the checkmark to activate a theme (stored in `config.json`)
|
||||||
4. **New theme** — Create a custom theme via admin or manually
|
4. **Compile SCSS** — Click the palette icon to force-compile SCSS to `assets/css_compiled/theme.css`
|
||||||
|
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, with a clickable **theme** root at the top
|
||||||
|
- `assets/css_compiled/` is hidden (runtime artefact, read-only)
|
||||||
|
- Click a file to open it in the CodeMirror editor
|
||||||
|
- Click a folder to open the **folder detail pane** on the right (new file, new folder, upload, delete)
|
||||||
|
- Folders containing the active file are auto-expanded
|
||||||
|
- Per folder there are action buttons: new file, new folder — they operate on the selected folder
|
||||||
|
- Per file there are action buttons: rename/move (pencil), delete (trash) — appear on hover
|
||||||
|
|
||||||
|
### Drag-and-drop
|
||||||
|
- Drag a file or folder onto another folder to move it
|
||||||
|
- Valid drop targets are highlighted during dragging
|
||||||
|
- The move is performed via an AJAX call — the page reloads automatically on success
|
||||||
|
- `theme.json` cannot be moved
|
||||||
|
|
||||||
|
### Editable file types
|
||||||
|
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
||||||
|
|
||||||
|
### Create a new file / folder
|
||||||
|
- Select the target folder in the sidebar (click the folder name), or use the root
|
||||||
|
- Click **New file** or **New folder** (in the folder-detail pane or at the top)
|
||||||
|
- For a 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
|
||||||
|
- **Move**: drag the file onto another folder (drag-and-drop), or click the pencil icon next to the file to go to the move form
|
||||||
|
- **Delete**: click the trash button next to the file in the sidebar, with confirmation; `theme.json` cannot be deleted
|
||||||
|
|
||||||
|
### Folder management
|
||||||
|
- Click a folder in the sidebar to open the **folder-detail pane** on the right
|
||||||
|
- **New file / new folder / upload into this folder**: use the buttons in the folder-detail pane
|
||||||
|
- **Delete folder**: delete button in the folder-detail pane (only empty folders)
|
||||||
|
- The theme root is also selectable (click the theme name at the top of the tree)
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -21,13 +93,15 @@ 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)
|
├── css_compiled/ # Generated by scssphp (read-only, do not edit manually, hidden in editor)
|
||||||
├── 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
|
||||||
@@ -53,9 +127,9 @@ themes/default/
|
|||||||
## 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
|
- `css_compiled/` is **read-only** — do not edit manually (hidden in the theme editor)
|
||||||
- `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
|
||||||
- After SCSS changes: remove `assets/css_compiled/theme.css` and `.mtime` to force recompilation
|
- Force compilation via the **Compile SCSS** button in admin (or remove `assets/css_compiled/theme.css` and `.mtime`)
|
||||||
|
|
||||||
## Layouts
|
## Layouts
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
codepress/
|
codepress/
|
||||||
├── cms/core/ # Core engine
|
├── cms/core/ # Core engine
|
||||||
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
|
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
|
||||||
│ ├── plugin/ # PluginManager, CMSAPI, AdminPluginAPI
|
│ ├── plugin/ # PluginManager, PluginAPIInterface, CMSAPI, AdminPluginAPI
|
||||||
│ ├── config.php # Config loader
|
│ ├── config.php # Config loader
|
||||||
│ └── index.php # Bootstrap
|
│ └── index.php # Bootstrap
|
||||||
├── admin/ # Admin console
|
├── admin/ # Admin console
|
||||||
@@ -13,8 +13,14 @@ codepress/
|
|||||||
│ └── theme/default/views/ # Twig templates
|
│ └── theme/default/views/ # Twig templates
|
||||||
├── themes/ # Themes (default, ...)
|
├── themes/ # Themes (default, ...)
|
||||||
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
|
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
|
||||||
|
│ └── <Plugin>/ # Each plugin has:
|
||||||
|
│ ├── <Plugin>.php # Main class
|
||||||
|
│ ├── plugin.json # Metadata + settings
|
||||||
|
│ ├── README.md # Documentation
|
||||||
|
│ ├── assets/ # CSS/JS/SCSS
|
||||||
|
│ └── language/ # Plugin translations (nl/, en/, ...; admin.php/site.php)
|
||||||
├── content/ # Content files (.md, .php, .html)
|
├── content/ # Content files (.md, .php, .html)
|
||||||
├── language/ # Language files (nl/, en/, de/)
|
├── language/ # Core CMS language files (nl/, en/, de/; site.php + admin.php)
|
||||||
├── guide/ # Guides (nl/, en/)
|
├── guide/ # Guides (nl/, en/)
|
||||||
├── public/ # Web root
|
├── public/ # Web root
|
||||||
│ ├── index.php # Website entry point
|
│ ├── index.php # Website entry point
|
||||||
@@ -22,3 +28,7 @@ codepress/
|
|||||||
├── config.json # Site configuration
|
├── config.json # Site configuration
|
||||||
└── version.php # Version info
|
└── version.php # Version info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Plugin i18n
|
||||||
|
|
||||||
|
Plugins have their own `language/` directory with translations. System plugins follow the admin language (`language/<lang>/admin.php`), content plugins follow the content language (`language/<lang>/site.php`). Fallback chain: selected language → plugin `default_language` → CMS site default → empty array. See `guide/en/codepress-developer/plugin-development.md` for details.
|
||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
## Plugin structure
|
## Plugin structure
|
||||||
|
|
||||||
|
Each plugin has its own folder under `plugins/`. The folder name must match the main plugin class name.
|
||||||
|
|
||||||
```
|
```
|
||||||
plugins/MyPlugin/
|
plugins/MyPlugin/
|
||||||
├── MyPlugin.php # Main plugin class (name = folder name)
|
├── MyPlugin.php # Main plugin class (name = folder name)
|
||||||
├── plugin.json # Plugin metadata
|
├── plugin.json # Plugin metadata + settings schema
|
||||||
├── config.json # Optional configuration
|
├── README.md # Plugin documentation
|
||||||
└── assets/ # Optional CSS/JS
|
├── config.json # Optional runtime configuration (overrides defaults)
|
||||||
├── css/
|
├── assets/ # Optional CSS/JS/SCSS
|
||||||
└── scss/
|
│ ├── css/
|
||||||
|
│ └── scss/
|
||||||
|
└── language/ # Translations
|
||||||
|
├── nl/
|
||||||
|
│ ├── admin.php # Admin labels (system plugins)
|
||||||
|
│ └── site.php # Front-end labels (content plugins)
|
||||||
|
└── en/
|
||||||
|
├── admin.php
|
||||||
|
└── site.php
|
||||||
```
|
```
|
||||||
|
|
||||||
## plugin.json
|
## plugin.json
|
||||||
@@ -22,7 +32,9 @@ plugins/MyPlugin/
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"type": "content",
|
"type": "content",
|
||||||
"essential": false,
|
"essential": false,
|
||||||
"hasConfig": false
|
"hasConfig": false,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,6 +49,54 @@ plugins/MyPlugin/
|
|||||||
| `type` | `"system"` or `"content"` | System (blue badge) or content (green badge) |
|
| `type` | `"system"` or `"content"` | System (blue badge) or content (green badge) |
|
||||||
| `essential` | boolean | Essential plugins cannot be edited/deleted |
|
| `essential` | boolean | Essential plugins cannot be edited/deleted |
|
||||||
| `hasConfig` | boolean | Shows a Config button in admin |
|
| `hasConfig` | boolean | Shows a Config button in admin |
|
||||||
|
| `default_language` | string | Fallback language for plugin translations (e.g. `nl`) |
|
||||||
|
| `settings` | array | Settings schema (see below) |
|
||||||
|
|
||||||
|
## Settings schema
|
||||||
|
|
||||||
|
When `hasConfig: true`, define settings in the `settings` array:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "max_items",
|
||||||
|
"type": "number",
|
||||||
|
"default": 10,
|
||||||
|
"label_key": "setting_max_items",
|
||||||
|
"help_key": "setting_max_items_help"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "required_roles",
|
||||||
|
"type": "multi-select",
|
||||||
|
"default": ["admin"],
|
||||||
|
"options": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"content-manager": "Content Manager"
|
||||||
|
},
|
||||||
|
"label_key": "setting_required_roles",
|
||||||
|
"help_key": "setting_required_roles_help",
|
||||||
|
"option_label_key": "role_options"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-setting fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `key` | Setting key (stored in `config.json`) |
|
||||||
|
| `type` | `text`, `checkbox`, `number`, `select`, `multi-select` |
|
||||||
|
| `default` | Default value |
|
||||||
|
| `label` | Hardcoded label (fallback when no `label_key` or when translation missing) |
|
||||||
|
| `help` | Hardcoded help text (fallback when no `help_key`) |
|
||||||
|
| `label_key` | Key in the plugin's `language/<lang>/admin.php` for a translated label |
|
||||||
|
| `help_key` | Key in the plugin's `language/<lang>/admin.php` for translated help text |
|
||||||
|
| `option_label_key` | Key pointing to an array of translated option labels for `select`/`multi-select` |
|
||||||
|
| `options` | Options for `select`/`multi-select` (`{value: label}`) |
|
||||||
|
|
||||||
|
The admin loads defaults from `plugin.json` and overrides them with values from `config.json`. The plugin reads the resolved values via `PluginManager::getPluginConfig()` or its own `getPluginConfig()` method.
|
||||||
|
|
||||||
## Plugin class example
|
## Plugin class example
|
||||||
|
|
||||||
@@ -63,8 +123,11 @@ class MyPlugin
|
|||||||
|
|
||||||
public function getSidebarContent(): string
|
public function getSidebarContent(): string
|
||||||
{
|
{
|
||||||
|
// Fetch plugin translations (content plugin = front-end language)
|
||||||
|
$t = $this->api ? $this->api->getPluginTranslations('MyPlugin') : [];
|
||||||
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
|
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
|
||||||
return '<p>Current page: ' . htmlspecialchars($title) . '</p>';
|
$label = $t['current_page'] ?? 'Current page';
|
||||||
|
return '<p>' . htmlspecialchars($label) . ': ' . htmlspecialchars($title) . '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -76,20 +139,122 @@ The plugin class is automatically loaded by `PluginManager` when the plugin is l
|
|||||||
The API is injected via `setAPI()`, not via a static method. In the front-end context this is a `CMSAPI` instance, in the admin context an `AdminPluginAPI` instance. Both implement `PluginAPIInterface`.
|
The API is injected via `setAPI()`, not via a static method. In the front-end context this is a `CMSAPI` instance, in the admin context an `AdminPluginAPI` instance. Both implement `PluginAPIInterface`.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Front-end API (CMSAPI)
|
// Front-end API (CMSAPI) - for content plugins
|
||||||
$this->api->getCurrentPageTitle();
|
$this->api->getCurrentPageTitle();
|
||||||
$this->api->getMenu();
|
$this->api->getMenu();
|
||||||
$this->api->getConfig('site_title');
|
$this->api->getConfig('site_title');
|
||||||
$this->api->getCurrentLanguage();
|
$this->api->getCurrentLanguage();
|
||||||
$this->api->isHomepage();
|
$this->api->isHomepage();
|
||||||
$this->api->createUrl('about-us');
|
$this->api->createUrl('about-us');
|
||||||
|
$this->api->getPluginTranslations('MyPlugin'); // front-end language
|
||||||
|
$this->api->t('current_page', 'MyPlugin'); // translation helper
|
||||||
|
|
||||||
// Admin API (AdminPluginAPI)
|
// Admin API (AdminPluginAPI) - for system plugins
|
||||||
$this->api->getConfig('analytics.enabled');
|
$this->api->getConfig('analytics.enabled');
|
||||||
$this->api->getContentDir();
|
$this->api->getContentDir();
|
||||||
$this->api->getEnabledPlugins();
|
$this->api->getEnabledPlugins();
|
||||||
|
$this->api->getAdminLanguage(); // active admin language
|
||||||
|
$this->api->getPluginTranslations('MyPlugin'); // admin language
|
||||||
|
$this->api->t('menu_label', 'MyPlugin'); // translation helper
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Language support (i18n)
|
||||||
|
|
||||||
|
Each plugin can provide translations in `language/<lang>/`. System plugins use `admin.php`, content plugins use `site.php`.
|
||||||
|
|
||||||
|
### Language file format
|
||||||
|
|
||||||
|
`language/en/admin.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'My Plugin',
|
||||||
|
|
||||||
|
// Page
|
||||||
|
'page_title' => 'My Plugin',
|
||||||
|
'current_page' => 'Current page',
|
||||||
|
|
||||||
|
// Settings (label_key/help_key point here)
|
||||||
|
'setting_max_items' => 'Maximum number of items',
|
||||||
|
'setting_max_items_help' => 'Number of items shown.',
|
||||||
|
'role_options' => [
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'content-manager' => 'Content Manager',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fallback chain
|
||||||
|
|
||||||
|
Plugin translations are resolved in this order:
|
||||||
|
|
||||||
|
1. **Selected language** — `language/<selected>/admin.php` (or `site.php`)
|
||||||
|
2. **Plugin default_language** — `plugin.json` `default_language` (e.g. `nl`)
|
||||||
|
3. **CMS site default** — `config.language.default`
|
||||||
|
4. **Empty array** — the key is shown unchanged
|
||||||
|
|
||||||
|
### System vs content plugins
|
||||||
|
|
||||||
|
- **System plugins** (`type: "system"`) follow the selected **admin language** (`config.admin_language`). Translations live in `language/<lang>/admin.php`.
|
||||||
|
- **Content plugins** (`type: "content"`) follow the selected **content language** (from the URL or `config.language.default`). Translations live in `language/<lang>/site.php`.
|
||||||
|
|
||||||
|
### Fetching translations in a plugin
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In handleAdminRoute() or getSidebarContent():
|
||||||
|
$t = $this->api->getPluginTranslations('MyPlugin');
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
echo htmlspecialchars($tr('page_title'));
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the single-key helper:
|
||||||
|
|
||||||
|
```php
|
||||||
|
echo htmlspecialchars($this->api->t('page_title', 'MyPlugin'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translating the menu label
|
||||||
|
|
||||||
|
In `getAdminMenu()`, add `label_key`. The admin sidebar shows the translation via `plugin_menu_label()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getAdminMenu(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'plugin' => 'MyPlugin',
|
||||||
|
'route' => 'my-plugin',
|
||||||
|
'label' => 'My Plugin', // fallback
|
||||||
|
'label_key' => 'menu_label', // points to language/<lang>/admin.php
|
||||||
|
'icon' => 'bi-puzzle',
|
||||||
|
'section' => 'general', // or 'system'
|
||||||
|
'permission' => 'my-plugin',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translating setting labels
|
||||||
|
|
||||||
|
In `plugin.json` `settings`, use `label_key`/`help_key`/`option_label_key` instead of hardcoded `label`/`help`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "max_items",
|
||||||
|
"label_key": "setting_max_items",
|
||||||
|
"help_key": "setting_max_items_help",
|
||||||
|
"type": "number",
|
||||||
|
"default": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin's `handlePluginsConfig()` resolves these via the plugin translations; if a translation is missing it falls back to `label`/`help` from `plugin.json`.
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
Plugins can implement the following methods for automatic hook registration:
|
Plugins can implement the following methods for automatic hook registration:
|
||||||
@@ -115,25 +280,72 @@ Plugins can add custom admin pages via `getAdminMenu()` and `handleAdminRoute()`
|
|||||||
```php
|
```php
|
||||||
public function getAdminMenu(): array
|
public function getAdminMenu(): array
|
||||||
{
|
{
|
||||||
|
$config = $this->getPluginConfig();
|
||||||
|
$requiredRoles = $config['required_roles'] ?? ['admin'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'plugin' => 'MyPlugin',
|
'plugin' => 'MyPlugin',
|
||||||
'route' => 'my-plugin',
|
'route' => 'my-plugin',
|
||||||
'label' => 'My Plugin',
|
'label' => 'My Plugin',
|
||||||
|
'label_key' => 'menu_label',
|
||||||
'icon' => 'bi-puzzle',
|
'icon' => 'bi-puzzle',
|
||||||
'section' => 'general', // or 'system'
|
'section' => 'general', // or 'system'
|
||||||
|
'permission' => 'my-plugin',
|
||||||
|
'required_roles' => $requiredRoles,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleAdminRoute(string $action): ?string
|
public function handleAdminRoute(string $action): ?string
|
||||||
{
|
{
|
||||||
return '<h2>My Plugin admin page</h2>';
|
$t = $this->api ? $this->api->getPluginTranslations('MyPlugin') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
return '<h2>' . htmlspecialchars($tr('page_title')) . '</h2>';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Only plugins listed in `enabled_plugins` are shown in the admin sidebar.
|
Only plugins listed in `enabled_plugins` are shown in the admin sidebar.
|
||||||
|
|
||||||
|
### Runtime config in a plugin
|
||||||
|
|
||||||
|
Plugins can read their runtime config (defaults from `plugin.json` `settings` + overrides from `config.json`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function getPluginConfig(): array
|
||||||
|
{
|
||||||
|
$pluginDir = dirname(__DIR__);
|
||||||
|
$pluginJsonFile = $pluginDir . '/plugin.json';
|
||||||
|
$configJsonFile = $pluginDir . '/config.json';
|
||||||
|
|
||||||
|
$defaults = [];
|
||||||
|
if (file_exists($pluginJsonFile)) {
|
||||||
|
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
|
||||||
|
foreach ($pluginJson['settings'] ?? [] as $setting) {
|
||||||
|
if (isset($setting['key'])) {
|
||||||
|
$defaults[$setting['key']] = $setting['default'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = [];
|
||||||
|
if (file_exists($configJsonFile)) {
|
||||||
|
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($defaults, $overrides);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via the shared method on PluginManager:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$config = $this->pluginManager->getPluginConfig('MyPlugin');
|
||||||
|
```
|
||||||
|
|
||||||
## Adding CSS
|
## Adding CSS
|
||||||
|
|
||||||
Plugins can provide a CSS URL via `getCssUrl()`:
|
Plugins can provide a CSS URL via `getCssUrl()`:
|
||||||
@@ -144,3 +356,5 @@ public function getCssUrl(): string
|
|||||||
return '/plugins/MyPlugin/assets/css/style.css';
|
return '/plugins/MyPlugin/assets/css/style.css';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The URL is passed to the front-end template via `plugin_css_urls`.
|
||||||
@@ -1,84 +1,132 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
The Content API is available in PHP content files (`.php`) and provides a safe, read-only interface to CMS data.
|
PHP content files (`.php`) run inside the CMS and have access to a safe,
|
||||||
|
read-only API via the `$api` variable. The output of such a file is
|
||||||
|
automatically placed into the active Twig layout at the `{{ content }}` spot.
|
||||||
|
|
||||||
## Usage in PHP content files
|
## How to pass content to the Twig template
|
||||||
|
|
||||||
|
There are **two ways** to send content from a `.php` file to the Twig template.
|
||||||
|
The CMS picks the right one automatically:
|
||||||
|
|
||||||
|
1. **Echo / print** — everything you echo (or that sits outside `<?php ?>` tags)
|
||||||
|
is captured and placed as `{{ content }}` in the layout.
|
||||||
|
2. **Return** — if you `return` a string, that string is used as `{{ content }}`
|
||||||
|
(any echoed output is then ignored).
|
||||||
|
|
||||||
|
Example with echo:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
|
?>
|
||||||
// Get all pages
|
<h1>Hello <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
$allPages = $api->getAllPages();
|
<p>Welcome to my page.</p>
|
||||||
// Result: ['index' => 'Home', 'about-us' => 'About Us', ...]
|
|
||||||
|
|
||||||
// Get a specific page
|
|
||||||
$page = $api->getPage('about-us');
|
|
||||||
// Result: ['title' => 'About Us', 'content' => '...', 'path' => 'about-us', 'layout' => 'full_content', 'metadata' => [...]]
|
|
||||||
|
|
||||||
// Get menu structure
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
// Result: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
|
||||||
|
|
||||||
// Get config value (dot notation)
|
|
||||||
$siteTitle = $api->getConfig('site_title');
|
|
||||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available methods
|
Example with return:
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
return '<h1>Hello ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
> The frontmatter (`---` block) is stripped by the CMS before the PHP code
|
||||||
|
> runs. The layout key determines which Twig template surrounds the content.
|
||||||
|
|
||||||
|
## What you cannot do
|
||||||
|
|
||||||
|
- You **cannot** set your own Twig variables from a `.php` file. PHP content
|
||||||
|
only feeds the `{{ content }}` placeholder. Other template variables
|
||||||
|
(`menu`, `breadcrumb`, `page_title`, etc.) are set by the CMS based on
|
||||||
|
frontmatter and config — not by your PHP code.
|
||||||
|
- You **cannot** call a ContentAPI from the outside; the class is only
|
||||||
|
instantiated inside `parsePHP()` and is never reachable via a URL.
|
||||||
|
|
||||||
|
## Available variables in your PHP file
|
||||||
|
|
||||||
|
The following variables are available inside a `.php` content file:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `$api` | `ContentAPI` | Read-only access to CMS data |
|
||||||
|
| `$pageMetadata` | `array` | The frontmatter metadata of this file |
|
||||||
|
|
||||||
|
## ContentAPI methods
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
- `getAllPages(): array` - All pages as `['path' => 'title']` pairs
|
- `getAllPages(): array` — All content entries (folders + files) as a list of `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` for files, `folder` for directories.
|
||||||
- `getPage(string $path): ?array` - Specific page with `title`, `content`, `path`, `layout`, `metadata`
|
- `getPage(string $path): ?array` — A specific page; returns `title`, `content`, `path`, `layout`, `metadata`. `$path` may include an extension.
|
||||||
- `pageExists(string $path): bool` - Check if a page exists
|
- `pageExists(string $path): bool` — Check whether a page exists
|
||||||
- `getCurrentPageTitle(): string` - Title of current page
|
- `getCurrentPageTitle(): string` — Title of the current page
|
||||||
- `getCurrentPagePath(): string` - Path of current page
|
- `getCurrentPagePath(): string` — Path of the current page
|
||||||
- `isHomepage(): bool` - Whether current page is the homepage
|
- `isHomepage(): bool` — Whether the current page is the homepage
|
||||||
|
|
||||||
### Menu & Navigation
|
### Menu & navigation
|
||||||
|
|
||||||
- `getMenu(): array` - Hierarchical menu structure with `title`, `path`, `children`, `active`
|
- `getMenu(): array` — Hierarchical menu structure with `title`, `path`, `children`, `active`
|
||||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Build a URL for a page
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Build a URL
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- `getConfig(string $key, mixed $default = null): mixed` - Config value via dot notation (e.g. `'features.search_enabled'`)
|
- `getConfig(string $key, mixed $default = null): mixed` — Config value via dot notation (e.g. `'features.search_enabled'`)
|
||||||
- `getSiteTitle(): string` - Site title from config
|
- `getSiteTitle(): string` — Site title from config
|
||||||
|
|
||||||
### Language
|
### Language
|
||||||
|
|
||||||
- `getCurrentLanguage(): string` - Current language code (e.g. `'nl'`)
|
- `getCurrentLanguage(): string` — Current language code (e.g. `'nl'`)
|
||||||
- `getAvailableLanguages(): array` - Available languages (e.g. `['nl', 'en']`)
|
- `getAvailableLanguages(): array` — Available languages
|
||||||
- `t(string $key): string` - Translate a language key
|
- `t(string $key): string` — Translate a language key
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
- `getSearchResults(): array` - Search results (empty if not searching)
|
- `getSearchResults(): array` — Search results (empty if not searching)
|
||||||
- `isSearching(): bool` - Whether a search is currently active
|
- `isSearching(): bool` — Whether a search is currently active
|
||||||
|
|
||||||
## Example: Show recent pages
|
### Author
|
||||||
|
|
||||||
|
- `getPageAuthor(): array` — Author metadata from frontmatter (`author_name`, `author_email`, `created`)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Show recent pages
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
$pages = $api->getAllPages();
|
$entries = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
<?php foreach ($entries as $entry): ?>
|
||||||
|
<?php if ($entry['type'] === 'folder') continue; // skip folders ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||||
<?= htmlspecialchars($title) ?>
|
<?= htmlspecialchars($entry['title']) ?>
|
||||||
|
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example: Using config values
|
### Use a config value
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||||
@@ -89,6 +137,44 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dynamic greeting based on language
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$lang = $api->getCurrentLanguage();
|
||||||
|
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||||
|
?>
|
||||||
|
<h1><?= $greeting ?> to <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Choosing a layout
|
||||||
|
|
||||||
|
The frontmatter `layout` key determines which Twig template surrounds your
|
||||||
|
content. Available layouts are defined in `themes/<active-theme>/theme.json`
|
||||||
|
under the `template` section. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
layout: sidebar-content
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
If you omit a layout, `config.default_template` is used, falling back to
|
||||||
|
`full_content`. The content always lands at the `{{ content }}` spot in that
|
||||||
|
template.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Always use `htmlspecialchars()` for output of user-content or API data.
|
||||||
|
- PHP content files have access to the server filesystem — be careful with
|
||||||
|
`include`, `require` or `file_get_contents` on external paths. Stay inside
|
||||||
|
the `content/` directory.
|
||||||
|
- The ContentAPI is read-only; you cannot modify files or config through it.
|
||||||
|
|
||||||
## CMSAPI (for plugins)
|
## CMSAPI (for plugins)
|
||||||
|
|
||||||
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
|
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
|
||||||
@@ -108,12 +194,12 @@ $this->api->createUrl('about-us');
|
|||||||
$this->api->translate('home');
|
$this->api->translate('home');
|
||||||
```
|
```
|
||||||
|
|
||||||
Additionally, CMSAPI provides:
|
In addition, CMSAPI has:
|
||||||
|
|
||||||
- `getCurrentPage(): array` - Full page data
|
- `getCurrentPage(): array` — Full page data
|
||||||
- `getCurrentPageUrl(): string` - URL of current page
|
- `getCurrentPageUrl(): string` — URL of the current page
|
||||||
- `getCurrentPageFileInfo(): ?array` - File info (created, modified)
|
- `getCurrentPageFileInfo(): ?array` — File info (created, modified)
|
||||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||||
- `executePhpFile(string $filePath): string` - Execute PHP file and capture output
|
- `executePhpFile(string $filePath): string` — Execute a PHP file and capture output
|
||||||
- `getFileContent(string $filePath): string` - Get content from PHP/HTML/Markdown file
|
- `getFileContent(string $filePath): string` — Get content from a PHP/HTML/Markdown file
|
||||||
- `contentFileExists(string $filename): bool` - Check if file exists in content directory
|
- `contentFileExists(string $filename): bool` — Check whether a file exists in the content directory
|
||||||
@@ -1,32 +1,125 @@
|
|||||||
# Content beheer
|
# Content beheer
|
||||||
|
|
||||||
## Bestanden beheren
|
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.
|
||||||
|
|
||||||
- **Nieuwe map** — Mappen structuur aanmaken
|
## Content editor
|
||||||
- **Nieuw bestand** — Pagina aanmaken (`.md`, `.php`, `.html`)
|
|
||||||
- **Bewerken** — Bestaande content wijzigen in CodeMirror editor
|
|
||||||
- **Hernoemen** — Bestands- of mapnamen aanpassen
|
|
||||||
- **Verplaatsen** — Content verplaatsen naar andere map
|
|
||||||
- **Verwijderen** — Content verwijderen
|
|
||||||
- **Map hernoemen** — Map naam wijzigen
|
|
||||||
|
|
||||||
## Editor (content-edit)
|
### Bestandsbrowser zijbalk
|
||||||
|
- Toont de geneste bestandsboom van `content/`, met een klikbare **content** root bovenaan
|
||||||
|
- Verborgen mappen (`.bak`, `.git`) en dotfiles worden overgeslagen
|
||||||
|
- Klik op een **bestand** om het te openen in de CodeMirror editor
|
||||||
|
- Klik op een **map** (mapnaam) om de map te selecteren — rechts verschijnt het **map detail paneel**
|
||||||
|
- De chevron (▶/▼) naast een map klapt deze in/uit zonder de map te selecteren
|
||||||
|
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||||
|
- Per map staan actie-knoppen: nieuw bestand (plus-icoon), nieuwe map (map-plus-icoon) — deze opereren op de geselecteerde map
|
||||||
|
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||||
|
|
||||||
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML)
|
### Bestanden verslepen (drag-and-drop)
|
||||||
- **Toolbar** voor snel Markdown invoeren
|
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||||
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
|
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||||
|
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||||
|
- Beveiliging: een map kan niet naar zichzelf of naar een submap verplaatst worden
|
||||||
|
- Path-traversal bescherming: alle paden blijven binnen de content map
|
||||||
|
|
||||||
## Layout selectie
|
### Bewerkbare bestandstypen
|
||||||
|
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Plugins op pagina's
|
#### PHP content bestanden
|
||||||
|
PHP bestanden kunnen op twee manieren content leveren:
|
||||||
|
1. **Echo** (output buffering): alles wat `echo`d of outside `<?php` staat wordt als content gebruikt
|
||||||
|
2. **Return** (callback stijl): `return '<h1>Hallo</h1>';` — de geretourneerde string wordt als content gebruikt
|
||||||
|
|
||||||
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie
|
In PHP bestanden is de `ContentAPI` beschikbaar via `$api`:
|
||||||
- Kies welke plugins in de sidebar verschijnen
|
```php
|
||||||
- **Volgorde aanpasbaar** met up/down knoppen
|
<?php
|
||||||
- De plugin volgorde wordt opgeslagen in de frontmatter `plugins:` key
|
$menu = $api->getMenu();
|
||||||
- Plugins worden **verbergen** als de gekozen layout geen sidebar heeft (bijv. `full_content`)
|
$pages = $api->getAllPages();
|
||||||
|
$config = $api->getConfig('site_title');
|
||||||
|
return '<h1>' . htmlspecialchars($config) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend URL's en bestandsnamen
|
||||||
|
De CMS zoekt bestanden in volgorde: `.md` → `.php` → `.html`. Als bestanden dezelfde naam hebben (bijv. `test.md` en `test.php`), opent `/nl/test` altijd `test.md`. Om een specifiek bestandstype te openen, voeg de extensie toe aan de URL:
|
||||||
|
- `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
|
||||||
|
- `/nl/test.php` → opent `test.php` (expliciete extensie)
|
||||||
|
- `/nl/test.html` → opent `test.html` (expliciete extensie)
|
||||||
|
|
||||||
|
De preview link in de editor gebruikt automatisch de extensie voor niet-`.md` bestanden.
|
||||||
|
|
||||||
|
### Image-grootte instellen (Markdown)
|
||||||
|
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
|
||||||
|
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||||
|
- Klik op **Nieuw bestand** (in het map-detail paneel of bovenaan)
|
||||||
|
- 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
|
||||||
|
- Het nieuwe bestand wordt aangemaakt in de geselecteerde map
|
||||||
|
|
||||||
|
### Bestand uploaden
|
||||||
|
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||||
|
- Klik op **Upload** (in het map-detail paneel of bovenaan) om bestanden naar de geselecteerde map te uploaden
|
||||||
|
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
|
||||||
|
- Path-traversal bescherming: doelmap moet binnen `content/` blijven
|
||||||
|
- Na de upload keer je terug naar de geselecteerde map
|
||||||
|
|
||||||
|
### Bestand hernoemen / verwijderen
|
||||||
|
- Open het bestand in de editor (klik op de bestandsnaam in de zijbalk)
|
||||||
|
- **Hernoemen**: bewerk het **Bestandsnaam** veld rechts boven de editor en klik op Opslaan — het bestand wordt direct hernoemd
|
||||||
|
- **Verwijderen**: klik op de verwijder-knop (prullenbak) naast het bestand in de zijbalk, met bevestiging
|
||||||
|
|
||||||
|
### Mappen beheer
|
||||||
|
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||||
|
- **Map hernoemen**: bewerk het **Mapnaam** veld in het map-detail paneel en klik op Opslaan
|
||||||
|
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel — ze opereren op de geselecteerde map
|
||||||
|
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||||
|
- De content-root is ook selecteerbaar (klik op "content" bovenaan de boom) — toont het map-detail paneel voor de root (hernoemen uitgeschakeld)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@@ -35,9 +128,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
|
|||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
layout: left_sidebar
|
layout: left_sidebar
|
||||||
plugins:
|
created: 2026-08-19 10:30:25
|
||||||
- HTMLBlock
|
edited: 2026-08-20 14:22:01
|
||||||
- Navigation
|
plugins: HTMLBlock, Navigation
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pagina titel
|
# Pagina titel
|
||||||
|
|||||||
@@ -15,23 +15,63 @@ CodePress kent vier rollen, gedefinieerd in `AdminAuth::ROLE_PERMISSIONS`:
|
|||||||
|
|
||||||
Rollen worden getoond met hun label via `AdminAuth::ROLE_LABELS`.
|
Rollen worden getoond met hun label via `AdminAuth::ROLE_LABELS`.
|
||||||
|
|
||||||
## Gebruiker toevoegen
|
## Gebruikerslijst (`/admin/users`)
|
||||||
|
|
||||||
1. Ga naar **Gebruikers**
|
De gebruikerslijst toont alle gebruikers met hun gebruikersnaam, rol, login e-mail en aanmaakdatum.
|
||||||
2. Vul gebruikersnaam in
|
|
||||||
3. Kies wachtwoord (opgeslagen als bcrypt hash)
|
|
||||||
4. Selecteer een rol
|
|
||||||
5. Klik **Toevoegen**
|
|
||||||
|
|
||||||
## Gebruiker bewerken
|
### Zoeken en filteren
|
||||||
|
|
||||||
- Wachtwoord wijzigen (nieuwe bcrypt hash)
|
- **Zoekveld**: zoek op gebruikersnaam, e-mail of auteur naam
|
||||||
- Rol wijzigen (beïnvloedt direct zichtbare routes en sidebar items)
|
- **Rol filter**: filter op een specifieke rol via de dropdown
|
||||||
|
- Klik op **Filteren** om de resultaten toe te passen
|
||||||
|
|
||||||
## Gebruiker verwijderen
|
### Nieuwe gebruiker aanmaken
|
||||||
|
|
||||||
- Kan niet voor eigen account
|
1. Klik op **Nieuwe gebruiker** (rechtsboven in de lijst)
|
||||||
- Bevestig met wachtwoord
|
2. Vul gebruikersnaam, wachtwoord (minimaal 8 tekens), e-mail, auteur naam en auteur e-mail in
|
||||||
|
3. Selecteer een rol
|
||||||
|
4. Klik **Gebruiker toevoegen**
|
||||||
|
5. Je wordt automatisch doorgestuurd naar de profiel pagina van de nieuwe gebruiker
|
||||||
|
|
||||||
|
## Profiel bewerken (`/admin/users-edit?user=<naam>`)
|
||||||
|
|
||||||
|
Klik op een gebruiker in de lijst om het profiel te bewerken. De profiel pagina bevat drie secties:
|
||||||
|
|
||||||
|
### Profiel gegevens
|
||||||
|
- Login e-mail, auteur naam en auteur e-mail kunnen worden gewijzigd
|
||||||
|
- De gebruikersnaam kan niet worden gewijzigd
|
||||||
|
|
||||||
|
### Wachtwoord wijzigen
|
||||||
|
- Vul een nieuw wachtwoord in (minimaal 8 tekens)
|
||||||
|
- Bevestig het wachtwoord
|
||||||
|
- Het wachtwoord wordt opgeslagen als bcrypt hash
|
||||||
|
|
||||||
|
### Rol wijzigen
|
||||||
|
- Toont de huidige rol met een gekleurde badge
|
||||||
|
- Selecteer een nieuwe rol uit de dropdown
|
||||||
|
- De wijziging beïnvloedt direct de zichtbare admin routes en sidebar items
|
||||||
|
|
||||||
|
### Gebruiker verwijderen (Gevarenzone)
|
||||||
|
- Alleen zichtbaar voor andere gebruikers (niet voor je eigen account)
|
||||||
|
- Bevestiging via JavaScript dialog
|
||||||
|
- Na verwijderen keer je terug naar de gebruikerslijst
|
||||||
|
|
||||||
|
## Admin wachtwoord reset via CLI
|
||||||
|
|
||||||
|
Als de admin is uitgesloten (bijv. door brute-force lockout of vergeten wachtwoord), kan het wachtwoord via de CLI worden gereset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset met een specifiek wachtwoord
|
||||||
|
php cli/reset-admin-password.php admin NieuwWachtwoord123
|
||||||
|
|
||||||
|
# Reset met een automatisch gegenereerd wachtwoord
|
||||||
|
php cli/reset-admin-password.php admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Het commando:
|
||||||
|
- Wijzigt het wachtwoord (als bcrypt hash)
|
||||||
|
- Wist de brute-force lockout voor de gebruiker
|
||||||
|
- Toont het nieuwe wachtwoord in de terminal
|
||||||
|
|
||||||
## Toegangscontrole
|
## Toegangscontrole
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,44 @@ Alleen actieve plugins worden getoond in de admin sidebar.
|
|||||||
4. Activeer de plugin
|
4. Activeer de plugin
|
||||||
|
|
||||||
Nieuwe plugins zijn standaard content-plugins. Wil je een systeem-plugin maken, voeg dan `"type": "system"` toe aan `plugin.json`.
|
Nieuwe plugins zijn standaard content-plugins. Wil je een systeem-plugin maken, voeg dan `"type": "system"` toe aan `plugin.json`.
|
||||||
|
|
||||||
|
## Plugin editor
|
||||||
|
|
||||||
|
Via **Bewerken** (potlood-icoon) in het plugin-overzicht open je de plugin-editor (`/admin/plugins-edit?plugin=<naam>`). Deze werkt hetzelfde als de content- en thema-editors.
|
||||||
|
|
||||||
|
### Bestandsbrowser zijbalk
|
||||||
|
- Toont de geneste bestandsboom van de plugin, met een klikbare **plugin** root bovenaan
|
||||||
|
- Klik op een bestand om het te openen in de CodeMirror editor
|
||||||
|
- Klik op een map om het **map detail paneel** rechts te openen (nieuw bestand, nieuwe map, uploaden, verwijderen)
|
||||||
|
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||||
|
- Per map staan actie-knoppen: nieuw bestand, nieuwe map — opereren op de geselecteerde map
|
||||||
|
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||||
|
|
||||||
|
### Bestanden verslepen (drag-and-drop)
|
||||||
|
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||||
|
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||||
|
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||||
|
- Beschermde plugins (zoals Navigation) kunnen niet bewerkt/verplaatst worden
|
||||||
|
|
||||||
|
### Bewerkbare bestandstypen
|
||||||
|
`.php`, `.json`, `.md`, `.html`, `.css`, `.scss`, `.js`
|
||||||
|
|
||||||
|
### Nieuw bestand / nieuwe map aanmaken
|
||||||
|
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||||
|
- Klik op **Nieuw bestand** of **Nieuwe map** (in het map-detail paneel of bovenaan)
|
||||||
|
- Bij nieuw bestand: geef een pad op binnen de plugin (bijv. `helper.php` of `assets/css/extra.css`)
|
||||||
|
- Submappen worden automatisch aangemaakt
|
||||||
|
|
||||||
|
### Bestand verplaatsen / verwijderen
|
||||||
|
- **Verplaatsen**: sleep het bestand naar een andere map (drag-and-drop), of klik op het potlood-icoon
|
||||||
|
- **Verwijderen**: klik op de prullenbak-knop naast het bestand in de zijbalk, met bevestiging
|
||||||
|
|
||||||
|
### Mappen beheer
|
||||||
|
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||||
|
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel
|
||||||
|
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||||
|
|
||||||
|
### Beveiliging
|
||||||
|
- Beschermde plugins (Navigation) kunnen niet bewerkt worden
|
||||||
|
- Alle acties vereisen CSRF token
|
||||||
|
- Path-traversal bescherming via `realpath()` + prefix-check op de plugin-map
|
||||||
@@ -3,15 +3,88 @@
|
|||||||
## Thema's beheren
|
## Thema's beheren
|
||||||
|
|
||||||
1. Ga naar **Thema** in admin menu
|
1. Ga naar **Thema** in admin menu
|
||||||
2. **Activeren** — Kies actief thema (wordt opgeslagen in `config.json`)
|
2. **Bewerken** — Klik op het potlood-icoon om themabestanden te bewerken (zie [Thema editor](#thema-editor))
|
||||||
3. **SCSS compileren** — Verwerk SCSS naar CSS (geforceerd)
|
3. **Activeren** — Klik op het vinkje om een thema te activeren (wordt opgeslagen in `config.json`)
|
||||||
4. **Nieuw thema** — Eigen thema aanmaken via admin of handmatig
|
4. **SCSS compileren** — Klik op het palet-icoon om SCSS geforceerd te compileren naar `assets/css_compiled/theme.css`
|
||||||
|
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, met een klikbare **thema** root bovenaan
|
||||||
|
- `assets/css_compiled/` wordt verborgen (runtime artefact, read-only)
|
||||||
|
- Klik op een bestand om het te openen in de CodeMirror editor
|
||||||
|
- Klik op een map om het **map detail paneel** rechts te openen (nieuw bestand, nieuwe map, uploaden, verwijderen)
|
||||||
|
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
|
||||||
|
- Per map staan actie-knoppen: nieuw bestand, nieuwe map — opereren op de geselecteerde map
|
||||||
|
- Per bestand staan actie-knoppen: hernoemen/verplaatsen (potlood), verwijderen (prullenbak) — verschijnen bij hover
|
||||||
|
|
||||||
|
### Bestanden verslepen (drag-and-drop)
|
||||||
|
- Sleep een bestand of map naar een andere map om het te verplaatsen
|
||||||
|
- Tijdens het slepen worden geldige doel-mappen highlight getoond
|
||||||
|
- De verplaatsing gebeurt via een AJAX-call — de pagina herlaadt automatisch na succes
|
||||||
|
- `theme.json` kan niet verplaatst worden
|
||||||
|
|
||||||
|
### Bewerkbare bestandstypen
|
||||||
|
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
|
||||||
|
|
||||||
|
### Nieuw bestand / nieuwe map aanmaken
|
||||||
|
- Selecteer de doelmap in de zijbalk (klik op de mapnaam), of gebruik de root
|
||||||
|
- Klik op **Nieuw bestand** of **Nieuwe map** (in het map-detail paneel of bovenaan)
|
||||||
|
- Bij 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
|
||||||
|
- **Verplaatsen**: sleep het bestand naar een andere map (drag-and-drop), of klik op het potlood-icoon naast het bestand om naar het verplaats-formulier te gaan
|
||||||
|
- **Verwijderen**: klik op de prullenbak-knop naast het bestand in de zijbalk, met bevestiging; `theme.json` kan niet verwijderd worden
|
||||||
|
|
||||||
|
### Mappen beheer
|
||||||
|
- Klik op een map in de zijbalk om het **map-detail paneel** rechts te openen
|
||||||
|
- **Nieuw bestand / Nieuwe map / Uploaden in deze map**: gebruik de knoppen in het map-detail paneel
|
||||||
|
- **Map verwijderen**: verwijder-knop in het map-detail paneel (alleen lege mappen)
|
||||||
|
- De thema-root is ook selecteerbaar (klik op de thema-naam bovenaan de boom)
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -21,13 +94,15 @@ 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)
|
├── css_compiled/ # Gegenereerd door scssphp (read-only, niet handmatig aanpassen, verborgen in editor)
|
||||||
├── 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
|
||||||
@@ -53,9 +128,9 @@ themes/default/
|
|||||||
## 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
|
- `css_compiled/` is **read-only** — niet handmatig aanpassen (wordt verborgen in de thema-editor)
|
||||||
- `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
|
||||||
- Na SCSS wijzigingen: verwijder `assets/css_compiled/theme.css` en `.mtime` om te forceren
|
- Forceer compilatie via de **SCSS compileren** knop in admin (of verwijder `assets/css_compiled/theme.css` en `.mtime`)
|
||||||
|
|
||||||
## Layouts
|
## Layouts
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
codepress/
|
codepress/
|
||||||
├── cms/core/ # Core engine
|
├── cms/core/ # Core engine
|
||||||
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
|
│ ├── class/ # CodePressCMS, ThemeManager, Logger, Analytics
|
||||||
│ ├── plugin/ # PluginManager, CMSAPI, AdminPluginAPI
|
│ ├── plugin/ # PluginManager, PluginAPIInterface, CMSAPI, AdminPluginAPI
|
||||||
│ ├── config.php # Config loader
|
│ ├── config.php # Config loader
|
||||||
│ └── index.php # Bootstrap
|
│ └── index.php # Bootstrap
|
||||||
├── admin/ # Admin console
|
├── admin/ # Admin console
|
||||||
@@ -13,8 +13,14 @@ codepress/
|
|||||||
│ └── theme/default/views/ # Twig templates
|
│ └── theme/default/views/ # Twig templates
|
||||||
├── themes/ # Thema's (default, ...)
|
├── themes/ # Thema's (default, ...)
|
||||||
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
|
├── plugins/ # Plugins (Dashboard, HTMLBlock, Navigation, ...)
|
||||||
|
│ └── <Plugin>/ # Elke plugin heeft:
|
||||||
|
│ ├── <Plugin>.php # Hoofd class
|
||||||
|
│ ├── plugin.json # Metadata + instellingen
|
||||||
|
│ ├── README.md # Documentatie
|
||||||
|
│ ├── assets/ # CSS/JS/SCSS
|
||||||
|
│ └── language/ # Plugin vertalingen (nl/, en/, ...; admin.php/site.php)
|
||||||
├── content/ # Content bestanden (.md, .php, .html)
|
├── content/ # Content bestanden (.md, .php, .html)
|
||||||
├── language/ # Taalbestanden (nl/, en/, de/)
|
├── language/ # Core CMS taalbestanden (nl/, en/, de/; site.php + admin.php)
|
||||||
├── guide/ # Handleidingen (nl/, en/)
|
├── guide/ # Handleidingen (nl/, en/)
|
||||||
├── public/ # Web root
|
├── public/ # Web root
|
||||||
│ ├── index.php # Website entry point
|
│ ├── index.php # Website entry point
|
||||||
@@ -22,3 +28,7 @@ codepress/
|
|||||||
├── config.json # Site configuratie
|
├── config.json # Site configuratie
|
||||||
└── version.php # Versie info
|
└── version.php # Versie info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Plugin i18n
|
||||||
|
|
||||||
|
Plugins hebben hun eigen `language/` map met vertalingen. Systeem plugins volgen de admin taal (`language/<lang>/admin.php`), content plugins volgen de content taal (`language/<lang>/site.php`). Fallback chain: geselecteerde taal → plugin `default_language` → CMS site default → lege array. Zie `guide/nl/codepress-developer/plugin-development.md` voor details.
|
||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
## Plugin structuur
|
## Plugin structuur
|
||||||
|
|
||||||
|
Elke plugin heeft zijn eigen map onder `plugins/`. De mapnaam moet overeenkomen met de hoofd plugin class naam.
|
||||||
|
|
||||||
```
|
```
|
||||||
plugins/MijnPlugin/
|
plugins/MijnPlugin/
|
||||||
├── MijnPlugin.php # Hoofd plugin class (naam = mapnaam)
|
├── MijnPlugin.php # Hoofd plugin class (naam = mapnaam)
|
||||||
├── plugin.json # Plugin metadata
|
├── plugin.json # Plugin metadata + instellingen-schema
|
||||||
├── config.json # Optionele configuratie
|
├── README.md # Plugin documentatie
|
||||||
└── assets/ # Optionele CSS/JS
|
├── config.json # Optionele runtime configuratie (overschrijft defaults)
|
||||||
├── css/
|
├── assets/ # Optionele CSS/JS/SCSS
|
||||||
└── scss/
|
│ ├── css/
|
||||||
|
│ └── scss/
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/
|
||||||
|
│ ├── admin.php # Admin labels (systeem plugins)
|
||||||
|
│ └── site.php # Front-end labels (content plugins)
|
||||||
|
└── en/
|
||||||
|
├── admin.php
|
||||||
|
└── site.php
|
||||||
```
|
```
|
||||||
|
|
||||||
## plugin.json
|
## plugin.json
|
||||||
@@ -22,7 +32,9 @@ plugins/MijnPlugin/
|
|||||||
"description": "Beschrijving",
|
"description": "Beschrijving",
|
||||||
"type": "content",
|
"type": "content",
|
||||||
"essential": false,
|
"essential": false,
|
||||||
"hasConfig": false
|
"hasConfig": false,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,7 +48,55 @@ plugins/MijnPlugin/
|
|||||||
| `description` | string | Korte beschrijving |
|
| `description` | string | Korte beschrijving |
|
||||||
| `type` | `"system"` of `"content"` | Systeem (blauwe badge) of content (groene badge) |
|
| `type` | `"system"` of `"content"` | Systeem (blauwe badge) of content (groene badge) |
|
||||||
| `essential` | boolean | Essentiële plugins kunnen niet worden bewerkt/verwijderd |
|
| `essential` | boolean | Essentiële plugins kunnen niet worden bewerkt/verwijderd |
|
||||||
| `hasConfig` | boolean | Toont een Config-knop in de admin |
|
| `hasConfig` | boolean | Toont een Config-knop in admin |
|
||||||
|
| `default_language` | string | Fallback taal voor plugin-vertalingen (bijv. `nl`) |
|
||||||
|
| `settings` | array | Instellingen-schema (zie hieronder) |
|
||||||
|
|
||||||
|
## Instellingen-schema
|
||||||
|
|
||||||
|
Als `hasConfig: true`, definieer je instellingen in het `settings` array:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "max_items",
|
||||||
|
"type": "number",
|
||||||
|
"default": 10,
|
||||||
|
"label_key": "setting_max_items",
|
||||||
|
"help_key": "setting_max_items_help"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "required_roles",
|
||||||
|
"type": "multi-select",
|
||||||
|
"default": ["admin"],
|
||||||
|
"options": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"content-manager": "Content Beheerder"
|
||||||
|
},
|
||||||
|
"label_key": "setting_required_roles",
|
||||||
|
"help_key": "setting_required_roles_help",
|
||||||
|
"option_label_key": "role_options"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-instelling velden
|
||||||
|
|
||||||
|
| Veld | Beschrijving |
|
||||||
|
|------|--------------|
|
||||||
|
| `key` | Instelling-sleutel (opgeslagen in `config.json`) |
|
||||||
|
| `type` | `text`, `checkbox`, `number`, `select`, `multi-select` |
|
||||||
|
| `default` | Standaardwaarde |
|
||||||
|
| `label` | Hardcoded label (fallback als geen `label_key` of als vertaling ontbreekt) |
|
||||||
|
| `help` | Hardcoded help-tekst (fallback als geen `help_key`) |
|
||||||
|
| `label_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaald label |
|
||||||
|
| `help_key` | Sleutel in plugin `language/<lang>/admin.php` voor vertaalde help-tekst |
|
||||||
|
| `option_label_key` | Sleutel naar een array met vertaalde optie-labels voor `select`/`multi-select` |
|
||||||
|
| `options` | Opties voor `select`/`multi-select` (`{waarde: label}`) |
|
||||||
|
|
||||||
|
De admin laadt defaults uit `plugin.json` en overschrijft ze met waarden uit `config.json`. De plugin leest de uiteindelijke waarden via `PluginManager::getPluginConfig()` of een eigen `getPluginConfig()` methode.
|
||||||
|
|
||||||
## Plugin class voorbeeld
|
## Plugin class voorbeeld
|
||||||
|
|
||||||
@@ -63,36 +123,141 @@ class MijnPlugin
|
|||||||
|
|
||||||
public function getSidebarContent(): string
|
public function getSidebarContent(): string
|
||||||
{
|
{
|
||||||
|
// Plugin vertalingen ophalen (content plugin = front-end taal)
|
||||||
|
$t = $this->api ? $this->api->getPluginTranslations('MijnPlugin') : [];
|
||||||
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
|
$title = $this->api ? $this->api->getCurrentPageTitle() : '';
|
||||||
return '<p>Huidige pagina: ' . htmlspecialchars($title) . '</p>';
|
$label = $t['current_page'] ?? 'Huidige pagina';
|
||||||
|
return '<p>' . htmlspecialchars($label) . ': ' . htmlspecialchars($title) . '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
De plugin class wordt automatisch geladen door `PluginManager` als de plugin in `enabled_plugins` staat in `config.json`.
|
De plugin class wordt automatisch geladen door `PluginManager` als de plugin in `enabled_plugins` staat in `config.json`.
|
||||||
|
|
||||||
## CMSAPI gebruiken
|
## API gebruiken
|
||||||
|
|
||||||
De API wordt geïnjecteerd via `setAPI()`, niet via een statische methode. In de front-end context is dit een `CMSAPI` instance, in de admin context een `AdminPluginAPI` instance. Beide implementeren `PluginAPIInterface`.
|
De API wordt geïnjecteerd via `setAPI()`. In de front-end context is dit een `CMSAPI` instance, in de admin context een `AdminPluginAPI` instance. Beide implementeren `PluginAPIInterface`.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Front-end API (CMSAPI)
|
// Front-end API (CMSAPI) - voor content plugins
|
||||||
$this->api->getCurrentPageTitle();
|
$this->api->getCurrentPageTitle();
|
||||||
$this->api->getMenu();
|
$this->api->getMenu();
|
||||||
$this->api->getConfig('site_title');
|
$this->api->getConfig('site_title');
|
||||||
$this->api->getCurrentLanguage();
|
$this->api->getCurrentLanguage();
|
||||||
$this->api->isHomepage();
|
$this->api->isHomepage();
|
||||||
$this->api->createUrl('over-ons');
|
$this->api->createUrl('over-ons');
|
||||||
|
$this->api->getPluginTranslations('MijnPlugin'); // front-end taal
|
||||||
|
$this->api->t('current_page', 'MijnPlugin'); // vertaalhelper
|
||||||
|
|
||||||
// Admin API (AdminPluginAPI)
|
// Admin API (AdminPluginAPI) - voor systeem plugins
|
||||||
$this->api->getConfig('analytics.enabled');
|
$this->api->getConfig('analytics.enabled');
|
||||||
$this->api->getContentDir();
|
$this->api->getContentDir();
|
||||||
$this->api->getEnabledPlugins();
|
$this->api->getEnabledPlugins();
|
||||||
|
$this->api->getAdminLanguage(); // actieve admin taal
|
||||||
|
$this->api->getPluginTranslations('MijnPlugin'); // admin taal
|
||||||
|
$this->api->t('menu_label', 'MijnPlugin'); // vertaalhelper
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Taal support (i18n)
|
||||||
|
|
||||||
|
Elke plugin kan vertalingen leveren in `language/<lang>/`. Systeem plugins gebruiken `admin.php`, content plugins gebruiken `site.php`.
|
||||||
|
|
||||||
|
### Taalbestand formaat
|
||||||
|
|
||||||
|
`language/nl/admin.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'Mijn Plugin',
|
||||||
|
|
||||||
|
// Pagina
|
||||||
|
'page_title' => 'Mijn Plugin',
|
||||||
|
'current_page' => 'Huidige pagina',
|
||||||
|
|
||||||
|
// Instellingen (label_key/help_key verwijzen hiernaar)
|
||||||
|
'setting_max_items' => 'Maximaal aantal items',
|
||||||
|
'setting_max_items_help' => 'Aantal items dat getoond wordt.',
|
||||||
|
'role_options' => [
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'content-manager' => 'Content Beheerder',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fallback chain
|
||||||
|
|
||||||
|
Plugin-vertalingen worden opgelost in deze volgorde:
|
||||||
|
|
||||||
|
1. **Geselecteerde taal** — `language/<geselecteerd>/admin.php` (of `site.php`)
|
||||||
|
2. **Plugin default_language** — `plugin.json` `default_language` (bijv. `nl`)
|
||||||
|
3. **CMS site default** — `config.language.default`
|
||||||
|
4. **Lege array** — de sleutel wordt ongewijzigd getoond
|
||||||
|
|
||||||
|
### Systeem vs content plugins
|
||||||
|
|
||||||
|
- **Systeem plugins** (`type: "system"`) volgen de geselecteerde **admin taal** (`config.admin_language`). Vertalingen staan in `language/<lang>/admin.php`.
|
||||||
|
- **Content plugins** (`type: "content"`) volgen de geselecteerde **content taal** (uit de URL of `config.language.default`). Vertalingen staan in `language/<lang>/site.php`.
|
||||||
|
|
||||||
|
### Vertalingen ophalen in een plugin
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In handleAdminRoute() of getSidebarContent():
|
||||||
|
$t = $this->api->getPluginTranslations('MijnPlugin');
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
echo htmlspecialchars($tr('page_title'));
|
||||||
|
```
|
||||||
|
|
||||||
|
Of gebruik de enkele-sleutel helper:
|
||||||
|
|
||||||
|
```php
|
||||||
|
echo htmlspecialchars($this->api->t('page_title', 'MijnPlugin'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu label vertalen
|
||||||
|
|
||||||
|
In `getAdminMenu()`, voeg `label_key` toe. De admin sidebar toont de vertaling via `plugin_menu_label()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getAdminMenu(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'plugin' => 'MijnPlugin',
|
||||||
|
'route' => 'mijn-plugin',
|
||||||
|
'label' => 'Mijn Plugin', // fallback
|
||||||
|
'label_key' => 'menu_label', // verwijst naar language/<lang>/admin.php
|
||||||
|
'icon' => 'bi-puzzle',
|
||||||
|
'section' => 'general', // of 'system'
|
||||||
|
'permission' => 'mijn-plugin',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instellingen-labels vertalen
|
||||||
|
|
||||||
|
In `plugin.json` `settings`, gebruik `label_key`/`help_key`/`option_label_key` in plaats van hardcoded `label`/`help`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "max_items",
|
||||||
|
"label_key": "setting_max_items",
|
||||||
|
"help_key": "setting_max_items_help",
|
||||||
|
"type": "number",
|
||||||
|
"default": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
De admin `handlePluginsConfig()` lost deze op via de plugin-vertalingen; als een vertaling ontbreekt valt het terug op `label`/`help` uit `plugin.json`.
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
Plugins kunnen de volgende methodes implementeren voor automatiche hook-registratie:
|
Plugins kunnen de volgende methodes implementeren voor automatische hook-registratie:
|
||||||
|
|
||||||
**Actions** (geen return waarde):
|
**Actions** (geen return waarde):
|
||||||
|
|
||||||
@@ -115,25 +280,72 @@ Plugins kunnen eigen admin-pagina's toevoegen via `getAdminMenu()` en `handleAdm
|
|||||||
```php
|
```php
|
||||||
public function getAdminMenu(): array
|
public function getAdminMenu(): array
|
||||||
{
|
{
|
||||||
|
$config = $this->getPluginConfig();
|
||||||
|
$requiredRoles = $config['required_roles'] ?? ['admin'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'plugin' => 'MijnPlugin',
|
'plugin' => 'MijnPlugin',
|
||||||
'route' => 'mijn-plugin',
|
'route' => 'mijn-plugin',
|
||||||
'label' => 'Mijn Plugin',
|
'label' => 'Mijn Plugin',
|
||||||
|
'label_key' => 'menu_label',
|
||||||
'icon' => 'bi-puzzle',
|
'icon' => 'bi-puzzle',
|
||||||
'section' => 'general', // of 'system'
|
'section' => 'general', // of 'system'
|
||||||
|
'permission' => 'mijn-plugin',
|
||||||
|
'required_roles' => $requiredRoles,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleAdminRoute(string $action): ?string
|
public function handleAdminRoute(string $action): ?string
|
||||||
{
|
{
|
||||||
return '<h2>Mijn Plugin admin pagina</h2>';
|
$t = $this->api ? $this->api->getPluginTranslations('MijnPlugin') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
return '<h2>' . htmlspecialchars($tr('page_title')) . '</h2>';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Alleen plugins die in `enabled_plugins` staan worden in de admin sidebar getoond.
|
Alleen plugins die in `enabled_plugins` staan worden in de admin sidebar getoond.
|
||||||
|
|
||||||
|
### Runtime config in een plugin
|
||||||
|
|
||||||
|
Plugins kunnen hun runtime config ophalen (defaults uit `plugin.json` `settings` + overrides uit `config.json`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function getPluginConfig(): array
|
||||||
|
{
|
||||||
|
$pluginDir = dirname(__DIR__);
|
||||||
|
$pluginJsonFile = $pluginDir . '/plugin.json';
|
||||||
|
$configJsonFile = $pluginDir . '/config.json';
|
||||||
|
|
||||||
|
$defaults = [];
|
||||||
|
if (file_exists($pluginJsonFile)) {
|
||||||
|
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
|
||||||
|
foreach ($pluginJson['settings'] ?? [] as $setting) {
|
||||||
|
if (isset($setting['key'])) {
|
||||||
|
$defaults[$setting['key']] = $setting['default'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = [];
|
||||||
|
if (file_exists($configJsonFile)) {
|
||||||
|
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($defaults, $overrides);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Of via de gedeelde methode op PluginManager:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$config = $this->pluginManager->getPluginConfig('MijnPlugin');
|
||||||
|
```
|
||||||
|
|
||||||
## CSS toevoegen
|
## CSS toevoegen
|
||||||
|
|
||||||
Plugins kunnen een CSS-URL leveren via `getCssUrl()`:
|
Plugins kunnen een CSS-URL leveren via `getCssUrl()`:
|
||||||
@@ -144,3 +356,5 @@ public function getCssUrl(): string
|
|||||||
return '/plugins/MijnPlugin/assets/css/style.css';
|
return '/plugins/MijnPlugin/assets/css/style.css';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
De URL wordt doorgegeven aan de front-end template via `plugin_css_urls`.
|
||||||
@@ -1,84 +1,132 @@
|
|||||||
# Content API
|
# Content API
|
||||||
|
|
||||||
De Content API is beschikbaar in PHP content bestanden (`.php`) en biedt een veilige, read-only interface tot CMS data.
|
PHP content bestanden (`.php`) draaien binnen het CMS en hebben toegang tot een
|
||||||
|
veilige, read-only API via de variabele `$api`. De output van zo'n bestand wordt
|
||||||
|
automatisch in de actieve Twig layout geplaatst op de plek van `{{ content }}`.
|
||||||
|
|
||||||
## Gebruik in PHP content bestanden
|
## Hoe je content doorgeeft aan de Twig template
|
||||||
|
|
||||||
|
Er zijn **twee manieren** om content vanuit een `.php` bestand naar het Twig
|
||||||
|
template te sturen. De CMS kiest automatisch de juiste:
|
||||||
|
|
||||||
|
1. **Echo / print** — alles wat je echoot (of wat buiten `<?php ?>` tags staat)
|
||||||
|
wordt opgevangen en als `{{ content }}` in de layout geplaatst.
|
||||||
|
2. **Return** — als je `return` gebruikt met een string, wordt die string als
|
||||||
|
`{{ content }}` gebruikt (echt output wordt dan genegeerd).
|
||||||
|
|
||||||
|
Voorbeeld met echo:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
|
?>
|
||||||
// Alle pagina's ophalen
|
<h1>Hallo <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
$allPages = $api->getAllPages();
|
<p>Welkom op mijn pagina.</p>
|
||||||
// Resultaat: ['index' => 'Home', 'over-ons' => 'Over ons', ...]
|
|
||||||
|
|
||||||
// Specifieke pagina ophalen
|
|
||||||
$page = $api->getPage('over-ons');
|
|
||||||
// Resultaat: ['title' => 'Over ons', 'content' => '...', 'path' => 'over-ons', 'layout' => 'full_content', 'metadata' => [...]]
|
|
||||||
|
|
||||||
// Menu structuur ophalen
|
|
||||||
$menu = $api->getMenu();
|
|
||||||
// Resultaat: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
|
|
||||||
|
|
||||||
// Config waarde ophalen (dot notatie)
|
|
||||||
$siteTitle = $api->getConfig('site_title');
|
|
||||||
$searchEnabled = $api->getConfig('features.search_enabled', false);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beschikbare methods
|
Voorbeeld met return:
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
return '<h1>Hallo ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
|
||||||
|
```
|
||||||
|
|
||||||
|
> De frontmatter (`---` blok) wordt door het CMS gestript vóórdat de PHP code
|
||||||
|
> uitgevoerd wordt. De layout key bepaalt welke Twig template de content omringt.
|
||||||
|
|
||||||
|
## Wat kun je níét doen
|
||||||
|
|
||||||
|
- Je kunt **geen** eigen Twig variabelen zetten vanuit een `.php` bestand.
|
||||||
|
PHP content leverde alleen de `{{ content }}` placeholder. Andere template
|
||||||
|
variabelen (`menu`, `breadcrumb`, `page_title`, etc.) worden door het CMS
|
||||||
|
vastgesteld op basis van de frontmatter en config — niet door je PHP code.
|
||||||
|
- Je kunt **geen** van buitenaf een ContentAPI aanroepen; de class wordt alleen
|
||||||
|
binnen `parsePHP()` geïnstantieerd en is nooit via een URL bereikbaar.
|
||||||
|
|
||||||
|
## Beschikbare variabelen in je PHP bestand
|
||||||
|
|
||||||
|
Binnen een `.php` content bestand zijn de volgende variabelen beschikbaar:
|
||||||
|
|
||||||
|
| Variabele | Type | Omschrijving |
|
||||||
|
|-----------|------|--------------|
|
||||||
|
| `$api` | `ContentAPI` | Read-only toegang tot CMS data |
|
||||||
|
| `$pageMetadata` | `array` | De frontmatter metadata van dit bestand |
|
||||||
|
|
||||||
|
## ContentAPI methods
|
||||||
|
|
||||||
### Pagina's
|
### Pagina's
|
||||||
|
|
||||||
- `getAllPages(): array` - Alle pagina's als `['pad' => 'titel']` pairs
|
- `getAllPages(): array` — Alle content entries (mappen + bestanden) als een list van `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
|
||||||
- `getPage(string $path): ?array` - Specifieke pagina met `title`, `content`, `path`, `layout`, `metadata`
|
- `getPage(string $path): ?array` — Specifieke pagina; levert `title`, `content`, `path`, `layout`, `metadata`. `$path` mag een extensie bevatten.
|
||||||
- `pageExists(string $path): bool` - Controleer of een pagina bestaat
|
- `pageExists(string $path): bool` — Controleer of een pagina bestaat
|
||||||
- `getCurrentPageTitle(): string` - Titel van huidige pagina
|
- `getCurrentPageTitle(): string` — Titel van de huidige pagina
|
||||||
- `getCurrentPagePath(): string` - Pad van huidige pagina
|
- `getCurrentPagePath(): string` — Pad van de huidige pagina
|
||||||
- `isHomepage(): bool` - Of huidige pagina de homepage is
|
- `isHomepage(): bool` — Of de huidige pagina de homepage is
|
||||||
|
|
||||||
### Menu & Navigatie
|
### Menu & navigatie
|
||||||
|
|
||||||
- `getMenu(): array` - Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
- `getMenu(): array` — Hiërarchische menu structuur met `title`, `path`, `children`, `active`
|
||||||
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Bouw een URL voor een pagina
|
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` — Bouw een URL
|
||||||
|
|
||||||
### Configuratie
|
### Configuratie
|
||||||
|
|
||||||
- `getConfig(string $key, mixed $default = null): mixed` - Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
- `getConfig(string $key, mixed $default = null): mixed` — Config waarde via dot notatie (bijv. `'features.search_enabled'`)
|
||||||
- `getSiteTitle(): string` - Site titel uit config
|
- `getSiteTitle(): string` — Site titel uit config
|
||||||
|
|
||||||
### Taal
|
### Taal
|
||||||
|
|
||||||
- `getCurrentLanguage(): string` - Huidige taal code (bijv. `'nl'`)
|
- `getCurrentLanguage(): string` — Huidige taal code (bijv. `'nl'`)
|
||||||
- `getAvailableLanguages(): array` - Beschikbare talen (bijv. `['nl', 'en']`)
|
- `getAvailableLanguages(): array` — Beschikbare talen
|
||||||
- `t(string $key): string` - Vertaal een language key
|
- `t(string $key): string` — Vertaal een language key
|
||||||
|
|
||||||
### Zoeken
|
### Zoeken
|
||||||
|
|
||||||
- `getSearchResults(): array` - Zoekresultaten (leeg als niet aan het zoeken)
|
- `getSearchResults(): array` — Zoekresultaten (leeg als niet aan het zoeken)
|
||||||
- `isSearching(): bool` - Of er momenteel gezocht wordt
|
- `isSearching(): bool` — Of er momenteel gezocht wordt
|
||||||
|
|
||||||
## Voorbeeld: Recentste pagina's tonen
|
### Auteur
|
||||||
|
|
||||||
|
- `getPageAuthor(): array` — Auteur metadata uit frontmatter (`author_name`, `author_email`, `created`)
|
||||||
|
|
||||||
|
## Voorbeelden
|
||||||
|
|
||||||
|
### Recente pagina's tonen
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
$pages = $api->getAllPages();
|
$entries = $api->getAllPages();
|
||||||
$currentLang = $api->getCurrentLanguage();
|
$currentLang = $api->getCurrentLanguage();
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
|
<?php foreach ($entries as $entry): ?>
|
||||||
|
<?php if ($entry['type'] === 'folder') continue; // sla mappen over ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
|
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
|
||||||
<?= htmlspecialchars($title) ?>
|
<?= htmlspecialchars($entry['title']) ?>
|
||||||
|
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Voorbeeld: Config waarde gebruiken
|
### Config waarde gebruiken
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
<?php
|
<?php
|
||||||
/** @var ContentAPI $api */
|
/** @var ContentAPI $api */
|
||||||
if ($api->getConfig('features.search_enabled', false)): ?>
|
if ($api->getConfig('features.search_enabled', false)): ?>
|
||||||
@@ -89,9 +137,48 @@ if ($api->getConfig('features.search_enabled', false)): ?>
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dynamische begroeting op basis van taal
|
||||||
|
|
||||||
|
```php
|
||||||
|
---
|
||||||
|
layout: full_content
|
||||||
|
---
|
||||||
|
<?php
|
||||||
|
/** @var ContentAPI $api */
|
||||||
|
$lang = $api->getCurrentLanguage();
|
||||||
|
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
|
||||||
|
?>
|
||||||
|
<h1><?= $greeting ?> op <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout kiezen
|
||||||
|
|
||||||
|
De frontmatter `layout` key bepaalt welke Twig template je content omringt. De
|
||||||
|
beschikbare layouts staan in `themes/<actief-thema>/theme.json` onder de
|
||||||
|
`template` sectie. Bijvoorbeeld:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
layout: sidebar-content
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Als je geen layout opgeeft, wordt `config.default_template` gebruikt, met als
|
||||||
|
fallback `full_content`. De content komt altijd terecht op de `{{ content }}`
|
||||||
|
plek in die template.
|
||||||
|
|
||||||
|
## Veiligheid
|
||||||
|
|
||||||
|
- Gebruik altijd `htmlspecialchars()` voor output van user-content of API data.
|
||||||
|
- PHP content bestanden hebben toegang tot het bestandssysteem van de server —
|
||||||
|
wees voorzichtig met `include`, `require` of `file_get_contents` op externe
|
||||||
|
paden. Blijf binnen de `content/` map.
|
||||||
|
- De ContentAPI is read-only; je kunt er geen bestanden of config mee wijzigen.
|
||||||
|
|
||||||
## CMSAPI (voor plugins)
|
## CMSAPI (voor plugins)
|
||||||
|
|
||||||
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare methods:
|
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare
|
||||||
|
methods:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$this->api->getCurrentPageTitle();
|
$this->api->getCurrentPageTitle();
|
||||||
@@ -110,10 +197,10 @@ $this->api->translate('home');
|
|||||||
|
|
||||||
Daarnaast heeft de CMSAPI:
|
Daarnaast heeft de CMSAPI:
|
||||||
|
|
||||||
- `getCurrentPage(): array` - Volledige pagina data
|
- `getCurrentPage(): array` — Volledige pagina data
|
||||||
- `getCurrentPageUrl(): string` - URL van huidige pagina
|
- `getCurrentPageUrl(): string` — URL van huidige pagina
|
||||||
- `getCurrentPageFileInfo(): ?array` - Bestandsinfo (created, modified)
|
- `getCurrentPageFileInfo(): ?array` — Bestandsinfo (created, modified)
|
||||||
- `getBreadcrumb(): string` - Breadcrumb HTML
|
- `getBreadcrumb(): string` — Breadcrumb HTML
|
||||||
- `executePhpFile(string $filePath): string` - Voer PHP bestand uit en vang output op
|
- `executePhpFile(string $filePath): string` — Voer PHP bestand uit en vang output op
|
||||||
- `getFileContent(string $filePath): string` - Haal content uit PHP/HTML/Markdown bestand
|
- `getFileContent(string $filePath): string` — Haal content uit PHP/HTML/Markdown bestand
|
||||||
- `contentFileExists(string $filename): bool` - Controleer of bestand bestaat in content map
|
- `contentFileExists(string $filename): bool` — Controleer of bestand bestaat in content map
|
||||||
@@ -88,6 +88,29 @@ 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 oder einen Ordner aus der Seitenleiste.',
|
||||||
|
'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.',
|
||||||
@@ -97,6 +120,11 @@ return [
|
|||||||
'no_filter_results' => 'Keine Ergebnisse für diesen Filter.',
|
'no_filter_results' => 'Keine Ergebnisse für diesen Filter.',
|
||||||
'new_folder_title' => 'Neuen Ordner erstellen',
|
'new_folder_title' => 'Neuen Ordner erstellen',
|
||||||
'folder_name' => 'Ordnername',
|
'folder_name' => 'Ordnername',
|
||||||
|
'folder_name_placeholder' => 'Ordnername',
|
||||||
|
'folder_rename_help' => 'Bearbeiten Sie den Ordnernamen und klicken Sie auf Speichern zum Umbenennen.',
|
||||||
|
'files' => 'Dateien',
|
||||||
|
'subfolders' => 'Unterordner',
|
||||||
|
'expand_collapse' => 'Ausklappen/Einklappen',
|
||||||
'name_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche.',
|
'name_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche.',
|
||||||
|
|
||||||
// Content edit / new
|
// Content edit / new
|
||||||
@@ -105,6 +133,8 @@ return [
|
|||||||
'visible_plugins' => 'Sichtbare Plugins',
|
'visible_plugins' => 'Sichtbare Plugins',
|
||||||
'new_page' => 'Neue Seite',
|
'new_page' => 'Neue Seite',
|
||||||
'file_type' => 'Dateityp',
|
'file_type' => 'Dateityp',
|
||||||
|
'created' => 'Erstellt',
|
||||||
|
'edited' => 'Bearbeitet',
|
||||||
|
|
||||||
// Content dir form
|
// Content dir form
|
||||||
'rename_folder' => 'Ordner umbenennen',
|
'rename_folder' => 'Ordner umbenennen',
|
||||||
@@ -168,6 +198,11 @@ return [
|
|||||||
// Media
|
// Media
|
||||||
'media' => 'Medien',
|
'media' => 'Medien',
|
||||||
'no_media' => 'Keine Mediendateien gefunden.',
|
'no_media' => 'Keine Mediendateien gefunden.',
|
||||||
|
'media_insert' => 'Medien einfügen',
|
||||||
|
'media_filter' => 'Nach Dateinamen filtern...',
|
||||||
|
'media_loading' => 'Medien werden geladen...',
|
||||||
|
'media_empty' => 'Keine Mediendateien in content/ gefunden. Laden Sie zuerst Dateien über Medien im Menü hoch.',
|
||||||
|
'media_empty_plugin' => 'Keine Mediendateien im Assets-Ordner dieses Plugins gefunden. Platzieren Sie Dateien direkt im Ordner plugins/{plugin}/assets/, um sie hier einfügen zu können.',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
'new_plugin' => 'Neues Plugin',
|
'new_plugin' => 'Neues Plugin',
|
||||||
@@ -207,10 +242,32 @@ 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',
|
||||||
@@ -242,6 +299,19 @@ return [
|
|||||||
'no_users' => 'Keine Benutzer gefunden.',
|
'no_users' => 'Keine Benutzer gefunden.',
|
||||||
'new_user' => 'Neuer Benutzer',
|
'new_user' => 'Neuer Benutzer',
|
||||||
'add_user' => 'Benutzer hinzufügen',
|
'add_user' => 'Benutzer hinzufügen',
|
||||||
|
'users_list' => 'Benutzerliste',
|
||||||
|
'search_users' => 'Nach Benutzername, E-Mail oder Autor-Name suchen...',
|
||||||
|
'all_roles' => 'Alle Rollen',
|
||||||
|
'filter' => 'Filtern',
|
||||||
|
'delete' => 'Löschen',
|
||||||
|
'back_to_users' => 'Zurück zu Benutzer',
|
||||||
|
'profile_info' => 'Profilinformationen',
|
||||||
|
'change_password' => 'Passwort ändern',
|
||||||
|
'new_password' => 'Neues Passwort',
|
||||||
|
'confirm_password' => 'Passwort bestätigen',
|
||||||
|
'passwords_no_match' => 'Passwörter stimmen nicht überein.',
|
||||||
|
'danger_zone' => 'Gefahrenzone',
|
||||||
|
'delete_user' => 'Benutzer löschen',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login_title' => 'CodePress Admin - Anmeldung',
|
'login_title' => 'CodePress Admin - Anmeldung',
|
||||||
@@ -249,4 +319,15 @@ return [
|
|||||||
'password_label' => 'Passwort',
|
'password_label' => 'Passwort',
|
||||||
'login_btn' => 'Anmelden',
|
'login_btn' => 'Anmelden',
|
||||||
'back_to_website' => 'Zurück zur Website',
|
'back_to_website' => 'Zurück zur Website',
|
||||||
|
|
||||||
|
// Plugin editor (plugins-edit)
|
||||||
|
'plugin_files' => 'Plugin-Dateien',
|
||||||
|
'plugin_no_files' => 'Keine Dateien gefunden.',
|
||||||
|
'plugin_new_file' => 'Neue Datei',
|
||||||
|
'plugin_new_file_title' => 'Neue Datei erstellen',
|
||||||
|
'plugin_file_path' => 'Dateipfad innerhalb des Plugins',
|
||||||
|
'plugin_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterverzeichnisse werden automatisch erstellt.',
|
||||||
|
'plugin_not_editable' => 'Dieser Dateityp kann im Editor nicht bearbeitet werden.',
|
||||||
|
'plugin_select_file' => 'Wählen Sie eine Datei aus der Seitenleiste zum Bearbeiten.',
|
||||||
|
'plugin_upload_help' => 'Dateien werden in den Ordner assets/ dieses Plugins hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||||
];
|
];
|
||||||
@@ -88,6 +88,29 @@ 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 or folder from the sidebar.',
|
||||||
|
'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.',
|
||||||
@@ -97,6 +120,11 @@ return [
|
|||||||
'no_filter_results' => 'No results for this filter.',
|
'no_filter_results' => 'No results for this filter.',
|
||||||
'new_folder_title' => 'Create new folder',
|
'new_folder_title' => 'Create new folder',
|
||||||
'folder_name' => 'Folder name',
|
'folder_name' => 'Folder name',
|
||||||
|
'folder_name_placeholder' => 'folder name',
|
||||||
|
'folder_rename_help' => 'Edit the folder name and click save to rename it.',
|
||||||
|
'files' => 'files',
|
||||||
|
'subfolders' => 'subfolders',
|
||||||
|
'expand_collapse' => 'Expand/collapse',
|
||||||
'name_help' => 'Only letters, numbers, dots, underscores and dashes.',
|
'name_help' => 'Only letters, numbers, dots, underscores and dashes.',
|
||||||
|
|
||||||
// Content edit / new
|
// Content edit / new
|
||||||
@@ -168,6 +196,11 @@ return [
|
|||||||
// Media
|
// Media
|
||||||
'media' => 'Media',
|
'media' => 'Media',
|
||||||
'no_media' => 'No media files found.',
|
'no_media' => 'No media files found.',
|
||||||
|
'media_insert' => 'Insert media',
|
||||||
|
'media_filter' => 'Filter by filename...',
|
||||||
|
'media_loading' => 'Loading media...',
|
||||||
|
'media_empty' => 'No media files found in content/. Upload files first via Media in the menu.',
|
||||||
|
'media_empty_plugin' => 'No media files found in this plugin assets folder. Place files directly in plugins/{plugin}/assets/ to insert them here.',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
'new_plugin' => 'New plugin',
|
'new_plugin' => 'New plugin',
|
||||||
@@ -207,10 +240,32 @@ 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',
|
||||||
@@ -242,6 +297,19 @@ return [
|
|||||||
'no_users' => 'No users found.',
|
'no_users' => 'No users found.',
|
||||||
'new_user' => 'New user',
|
'new_user' => 'New user',
|
||||||
'add_user' => 'Add user',
|
'add_user' => 'Add user',
|
||||||
|
'users_list' => 'User list',
|
||||||
|
'search_users' => 'Search by username, email or author name...',
|
||||||
|
'all_roles' => 'All roles',
|
||||||
|
'filter' => 'Filter',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'back_to_users' => 'Back to users',
|
||||||
|
'profile_info' => 'Profile information',
|
||||||
|
'change_password' => 'Change password',
|
||||||
|
'new_password' => 'New password',
|
||||||
|
'confirm_password' => 'Confirm password',
|
||||||
|
'passwords_no_match' => 'Passwords do not match.',
|
||||||
|
'danger_zone' => 'Danger zone',
|
||||||
|
'delete_user' => 'Delete user',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login_title' => 'CodePress Admin - Login',
|
'login_title' => 'CodePress Admin - Login',
|
||||||
@@ -249,4 +317,39 @@ return [
|
|||||||
'password_label' => 'Password',
|
'password_label' => 'Password',
|
||||||
'login_btn' => 'Log in',
|
'login_btn' => 'Log in',
|
||||||
'back_to_website' => 'Back to website',
|
'back_to_website' => 'Back to website',
|
||||||
|
|
||||||
|
// Role switch (admin testing feature)
|
||||||
|
'role_switch' => 'Switch role',
|
||||||
|
'role_switch_title' => 'Switch role',
|
||||||
|
'role_switch_help' => 'Test the admin from another role. Your real account stays admin.',
|
||||||
|
'role_switch_btn' => 'Switch to role',
|
||||||
|
'role_testing' => 'Testing',
|
||||||
|
'role_reset_title' => 'Reset role',
|
||||||
|
'role_reset_btn' => 'Back to admin',
|
||||||
|
'role_reset_help' => 'Click below to return to your admin role.',
|
||||||
|
'role_override_active' => 'You are currently testing as',
|
||||||
|
'cannot_change_own_role' => 'You cannot change your own role.',
|
||||||
|
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
|
||||||
|
'created' => 'Created',
|
||||||
|
'edited' => 'Edited',
|
||||||
|
|
||||||
|
// Plugin config
|
||||||
|
'plugin_settings' => 'Settings',
|
||||||
|
'plugin_no_settings' => 'This plugin has no configurable settings.',
|
||||||
|
'plugin_info' => 'Plugin information',
|
||||||
|
'plugin_name' => 'Name',
|
||||||
|
'version' => 'Version',
|
||||||
|
'type' => 'Type',
|
||||||
|
'description' => 'Description',
|
||||||
|
|
||||||
|
// Plugin editor (plugins-edit)
|
||||||
|
'plugin_files' => 'Plugin files',
|
||||||
|
'plugin_no_files' => 'No files found.',
|
||||||
|
'plugin_new_file' => 'New file',
|
||||||
|
'plugin_new_file_title' => 'Create new file',
|
||||||
|
'plugin_file_path' => 'File path within plugin',
|
||||||
|
'plugin_file_path_help' => 'Only letters, digits, dots, underscores, hyphens and slashes. Subdirectories are created automatically.',
|
||||||
|
'plugin_not_editable' => 'This file type cannot be edited in the editor.',
|
||||||
|
'plugin_select_file' => 'Select a file from the sidebar to edit.',
|
||||||
|
'plugin_upload_help' => 'Files are uploaded to assets/ of this plugin. Allowed: images, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||||
];
|
];
|
||||||
@@ -77,7 +77,9 @@ 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',
|
||||||
@@ -88,6 +90,29 @@ 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 of map uit de zijbalk.',
|
||||||
|
'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.',
|
||||||
@@ -97,6 +122,11 @@ return [
|
|||||||
'no_filter_results' => 'Geen resultaten voor deze filter.',
|
'no_filter_results' => 'Geen resultaten voor deze filter.',
|
||||||
'new_folder_title' => 'Nieuwe map aanmaken',
|
'new_folder_title' => 'Nieuwe map aanmaken',
|
||||||
'folder_name' => 'Mapnaam',
|
'folder_name' => 'Mapnaam',
|
||||||
|
'folder_name_placeholder' => 'mapnaam',
|
||||||
|
'folder_rename_help' => 'Bewerk de mapnaam en klik op opslaan om te hernoemen.',
|
||||||
|
'files' => 'bestanden',
|
||||||
|
'subfolders' => 'submappen',
|
||||||
|
'expand_collapse' => 'Uitklappen/inklappen',
|
||||||
'name_help' => 'Alleen letters, cijfers, punten, underscores en streepjes.',
|
'name_help' => 'Alleen letters, cijfers, punten, underscores en streepjes.',
|
||||||
|
|
||||||
// Content edit / new
|
// Content edit / new
|
||||||
@@ -168,6 +198,11 @@ return [
|
|||||||
// Media
|
// Media
|
||||||
'media' => 'Media',
|
'media' => 'Media',
|
||||||
'no_media' => 'Geen media bestanden gevonden.',
|
'no_media' => 'Geen media bestanden gevonden.',
|
||||||
|
'media_insert' => 'Media invoegen',
|
||||||
|
'media_filter' => 'Filter op bestandsnaam...',
|
||||||
|
'media_loading' => 'Media laden...',
|
||||||
|
'media_empty' => 'Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.',
|
||||||
|
'media_empty_plugin' => 'Geen media bestanden gevonden in de assets-map van deze plugin. Plaats bestanden rechtstreeks in de map plugins/{plugin}/assets/ om ze hier te kunnen invoegen.',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
'new_plugin' => 'Nieuwe plugin',
|
'new_plugin' => 'Nieuwe plugin',
|
||||||
@@ -207,10 +242,32 @@ 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',
|
||||||
@@ -242,6 +299,19 @@ return [
|
|||||||
'no_users' => 'Geen gebruikers gevonden.',
|
'no_users' => 'Geen gebruikers gevonden.',
|
||||||
'new_user' => 'Nieuwe gebruiker',
|
'new_user' => 'Nieuwe gebruiker',
|
||||||
'add_user' => 'Gebruiker toevoegen',
|
'add_user' => 'Gebruiker toevoegen',
|
||||||
|
'users_list' => 'Gebruikerslijst',
|
||||||
|
'search_users' => 'Zoek op gebruikersnaam, e-mail of auteur naam...',
|
||||||
|
'all_roles' => 'Alle rollen',
|
||||||
|
'filter' => 'Filteren',
|
||||||
|
'delete' => 'Verwijderen',
|
||||||
|
'back_to_users' => 'Terug naar gebruikers',
|
||||||
|
'profile_info' => 'Profiel gegevens',
|
||||||
|
'change_password' => 'Wachtwoord wijzigen',
|
||||||
|
'new_password' => 'Nieuw wachtwoord',
|
||||||
|
'confirm_password' => 'Bevestig wachtwoord',
|
||||||
|
'passwords_no_match' => 'Wachtwoorden komen niet overeen.',
|
||||||
|
'danger_zone' => 'Gevarenzone',
|
||||||
|
'delete_user' => 'Gebruiker verwijderen',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login_title' => 'CodePress Admin - Login',
|
'login_title' => 'CodePress Admin - Login',
|
||||||
@@ -249,4 +319,39 @@ return [
|
|||||||
'password_label' => 'Wachtwoord',
|
'password_label' => 'Wachtwoord',
|
||||||
'login_btn' => 'Inloggen',
|
'login_btn' => 'Inloggen',
|
||||||
'back_to_website' => 'Terug naar website',
|
'back_to_website' => 'Terug naar website',
|
||||||
|
|
||||||
|
// Role switch (admin testing feature)
|
||||||
|
'role_switch' => 'Rol wisselen',
|
||||||
|
'role_switch_title' => 'Rol wisselen',
|
||||||
|
'role_switch_help' => 'Test de admin vanuit een andere rol. Je echte account blijft admin.',
|
||||||
|
'role_switch_btn' => 'Wissel naar rol',
|
||||||
|
'role_testing' => 'Testen',
|
||||||
|
'role_reset_title' => 'Rol terugzetten',
|
||||||
|
'role_reset_btn' => 'Terug naar admin',
|
||||||
|
'role_reset_help' => 'Klik hieronder om terug te keren naar je admin rol.',
|
||||||
|
'role_override_active' => 'Je test momenteel als',
|
||||||
|
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
|
||||||
|
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
|
||||||
|
'created' => 'Aangemaakt',
|
||||||
|
'edited' => 'Bewerkt',
|
||||||
|
|
||||||
|
// Plugin config
|
||||||
|
'plugin_settings' => 'Instellingen',
|
||||||
|
'plugin_no_settings' => 'Deze plugin heeft geen instelbare configuratie.',
|
||||||
|
'plugin_info' => 'Plugin informatie',
|
||||||
|
'plugin_name' => 'Naam',
|
||||||
|
'version' => 'Versie',
|
||||||
|
'type' => 'Type',
|
||||||
|
'description' => 'Beschrijving',
|
||||||
|
|
||||||
|
// Plugin editor (plugins-edit)
|
||||||
|
'plugin_files' => 'Plugin bestanden',
|
||||||
|
'plugin_no_files' => 'Geen bestanden gevonden.',
|
||||||
|
'plugin_new_file' => 'Nieuw bestand',
|
||||||
|
'plugin_new_file_title' => 'Nieuw bestand aanmaken',
|
||||||
|
'plugin_file_path' => 'Bestandspad binnen plugin',
|
||||||
|
'plugin_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt.',
|
||||||
|
'plugin_not_editable' => 'Dit bestandstype kan niet in de editor bewerkt worden.',
|
||||||
|
'plugin_select_file' => 'Selecteer een bestand uit de zijbalk om te bewerken.',
|
||||||
|
'plugin_upload_help' => 'Bestanden worden geüpload naar assets/ van deze plugin. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD.',
|
||||||
];
|
];
|
||||||
+117
-19
@@ -1,14 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard plugin: toont CMS-, content- en plugin-overzicht in het beheer.
|
||||||
|
*
|
||||||
|
* Systeem-plugin die een overzichtspagina biedt met site-informatie,
|
||||||
|
* content-statistieken en een lijst van geïnstalleerde plugins.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class Dashboard
|
class Dashboard
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginAPIInterface|null
|
||||||
|
*/
|
||||||
private ?PluginAPIInterface $api = null;
|
private ?PluginAPIInterface $api = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stelt de Plugin-API instantie in.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function setAPI(PluginAPIInterface $api): void
|
public function setAPI(PluginAPIInterface $api): void
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
$this->api = $api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de plugin-configuratie terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> Plugin-configuratie met title, viewable en type.
|
||||||
|
*/
|
||||||
public function getConfig(): array
|
public function getConfig(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -18,6 +45,12 @@ class Dashboard
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de admin-menu-items voor deze plugin.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||||
|
*/
|
||||||
public function getAdminMenu(): array
|
public function getAdminMenu(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -25,12 +58,24 @@ class Dashboard
|
|||||||
'plugin' => 'Dashboard',
|
'plugin' => 'Dashboard',
|
||||||
'route' => 'dashboard',
|
'route' => 'dashboard',
|
||||||
'label' => 'Dashboard',
|
'label' => 'Dashboard',
|
||||||
|
'label_key' => 'menu_label',
|
||||||
'icon' => 'bi-speedometer2',
|
'icon' => 'bi-speedometer2',
|
||||||
'section' => 'general',
|
'section' => 'general',
|
||||||
|
'permission' => 'dashboard',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwerkt een admin-route en toont het dashboard.
|
||||||
|
*
|
||||||
|
* Geeft een HTML-weergave van site-informatie, content-statistieken
|
||||||
|
* en een plugin-overzicht.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $action De uit te voeren actie.
|
||||||
|
* @return string|null De HTML-uitvoer van het dashboard, of null.
|
||||||
|
*/
|
||||||
public function handleAdminRoute(string $action): ?string
|
public function handleAdminRoute(string $action): ?string
|
||||||
{
|
{
|
||||||
$siteConfig = $this->getSiteConfig();
|
$siteConfig = $this->getSiteConfig();
|
||||||
@@ -39,6 +84,12 @@ class Dashboard
|
|||||||
$enabledPlugins = $this->api ? $this->api->getEnabledPlugins() : [];
|
$enabledPlugins = $this->api ? $this->api->getEnabledPlugins() : [];
|
||||||
$versionInfo = $this->api ? $this->api->getVersionInfo() : ['version' => '0.0.0'];
|
$versionInfo = $this->api ? $this->api->getVersionInfo() : ['version' => '0.0.0'];
|
||||||
|
|
||||||
|
// Plugin translations (admin context). Falls back to plugin default_language.
|
||||||
|
$t = $this->api ? $this->api->getPluginTranslations('Dashboard') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'pages' => $this->countFiles($contentDir, ['md', 'php', 'html']),
|
'pages' => $this->countFiles($contentDir, ['md', 'php', 'html']),
|
||||||
'directories' => $this->countDirs($contentDir),
|
'directories' => $this->countDirs($contentDir),
|
||||||
@@ -68,20 +119,20 @@ class Dashboard
|
|||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
?>
|
?>
|
||||||
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
|
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> <?= htmlspecialchars($tr('page_title')) ?></h2>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header"><i class="bi bi-info-circle"></i> Site informatie</div>
|
<div class="card-header"><i class="bi bi-info-circle"></i> <?= htmlspecialchars($tr('site_info')) ?></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<tr><td class="text-muted">Site titel</td><td><?= htmlspecialchars($siteTitle) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('site_title')) ?></td><td><?= htmlspecialchars($siteTitle) ?></td></tr>
|
||||||
<tr><td class="text-muted">Standaard taal</td><td><?= htmlspecialchars($defaultLang) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('default_lang')) ?></td><td><?= htmlspecialchars($defaultLang) ?></td></tr>
|
||||||
<tr><td class="text-muted">CodePress versie</td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('cms_version')) ?></td><td><?= htmlspecialchars($stats['cms_version']) ?></td></tr>
|
||||||
<tr><td class="text-muted">PHP versie</td><td><?= htmlspecialchars($stats['php_version']) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('php_version')) ?></td><td><?= htmlspecialchars($stats['php_version']) ?></td></tr>
|
||||||
<tr><td class="text-muted">Besturingssysteem</td><td><?= htmlspecialchars($stats['os']) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('os')) ?></td><td><?= htmlspecialchars($stats['os']) ?></td></tr>
|
||||||
<tr><td class="text-muted">Config geladen</td><td><?php if ($stats['config_exists']): ?><span class="badge bg-success">Ja</span><?php else: ?><span class="badge bg-danger">Nee</span><?php endif; ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('config_loaded')) ?></td><td><?php if ($stats['config_exists']): ?><span class="badge bg-success"><?= htmlspecialchars($tr('yes')) ?></span><?php else: ?><span class="badge bg-danger"><?= htmlspecialchars($tr('no')) ?></span><?php endif; ?></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,12 +140,12 @@ class Dashboard
|
|||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header"><i class="bi bi-folder2-open"></i> Content informatie</div>
|
<div class="card-header"><i class="bi bi-folder2-open"></i> <?= htmlspecialchars($tr('content_info')) ?></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<tr><td class="text-muted">Pagina's</td><td><span class="badge bg-primary"><?= $stats['pages'] ?></span></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('pages')) ?></td><td><span class="badge bg-primary"><?= $stats['pages'] ?></span></td></tr>
|
||||||
<tr><td class="text-muted">Mappen</td><td><span class="badge bg-warning text-dark"><?= $stats['directories'] ?></span></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('folders')) ?></td><td><span class="badge bg-warning text-dark"><?= $stats['directories'] ?></span></td></tr>
|
||||||
<tr><td class="text-muted">Content grootte</td><td><?= htmlspecialchars($stats['content_size']) ?></td></tr>
|
<tr><td class="text-muted"><?= htmlspecialchars($tr('content_size')) ?></td><td><?= htmlspecialchars($stats['content_size']) ?></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +154,8 @@ class Dashboard
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<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><i class="bi bi-plug"></i> Plugins</span>
|
<span><i class="bi bi-plug"></i> <?= htmlspecialchars($tr('plugins')) ?></span>
|
||||||
<a href="/admin/plugins" class="btn btn-sm btn-outline-secondary">Beheren</a>
|
<a href="/admin/plugins" class="btn btn-sm btn-outline-secondary"><?= htmlspecialchars($tr('manage')) ?></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
@@ -113,22 +164,22 @@ class Dashboard
|
|||||||
<td>
|
<td>
|
||||||
<i class="bi bi-plug-fill"></i> <?= htmlspecialchars($pluginName) ?>
|
<i class="bi bi-plug-fill"></i> <?= htmlspecialchars($pluginName) ?>
|
||||||
<?php if ($info['type'] === 'system'): ?>
|
<?php if ($info['type'] === 'system'): ?>
|
||||||
<span class="badge bg-secondary fs-7">systeem</span>
|
<span class="badge bg-secondary fs-7"><?= htmlspecialchars($tr('plugin_type_system')) ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge bg-info fs-7">content</span>
|
<span class="badge bg-info fs-7"><?= htmlspecialchars($tr('plugin_type_content')) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($info['enabled']): ?>
|
<?php if ($info['enabled']): ?>
|
||||||
<span class="badge bg-success">Actief</span>
|
<span class="badge bg-success"><?= htmlspecialchars($tr('active')) ?></span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge bg-secondary">Inactief</span>
|
<span class="badge bg-secondary"><?= htmlspecialchars($tr('inactive')) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($pluginOverview)): ?>
|
<?php if (empty($pluginOverview)): ?>
|
||||||
<tr><td colspan="2" class="text-muted text-center">Geen plugins gevonden.</td></tr>
|
<tr><td colspan="2" class="text-muted text-center"><?= htmlspecialchars($tr('no_plugins')) ?></td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,6 +190,12 @@ class Dashboard
|
|||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt de site-configuratie op via de AdminPluginAPI.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> De site-configuratie, of een lege array.
|
||||||
|
*/
|
||||||
private function getSiteConfig(): array
|
private function getSiteConfig(): array
|
||||||
{
|
{
|
||||||
if ($this->api === null) {
|
if ($this->api === null) {
|
||||||
@@ -151,6 +208,14 @@ class Dashboard
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bepaalt het type (system of content) van een plugin door het hoofdbestand te lezen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $pluginDir Pad naar de plugin-map.
|
||||||
|
* @param string $pluginName Naam van de plugin.
|
||||||
|
* @return string Het plugin-type ('system' of 'content').
|
||||||
|
*/
|
||||||
private function getPluginType(string $pluginDir, string $pluginName): string
|
private function getPluginType(string $pluginDir, string $pluginName): string
|
||||||
{
|
{
|
||||||
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
|
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
|
||||||
@@ -165,6 +230,16 @@ class Dashboard
|
|||||||
return 'content';
|
return 'content';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telt het aantal bestanden in een map, eventueel gefilterd op extensie.
|
||||||
|
*
|
||||||
|
* Slaat verborgen en met '-' voorafgegane bestanden en mappen over.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||||
|
* @param string[] $extensions Optionele lijst van toegestane extensies.
|
||||||
|
* @return int Het aantal gevonden bestanden.
|
||||||
|
*/
|
||||||
private function countFiles(string $dir, array $extensions = []): int
|
private function countFiles(string $dir, array $extensions = []): int
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) return 0;
|
if (!is_dir($dir)) return 0;
|
||||||
@@ -189,6 +264,15 @@ class Dashboard
|
|||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telt het aantal submappen in een map, recursief.
|
||||||
|
*
|
||||||
|
* Slaat verborgen en met '-' voorafgegane mappen over.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||||
|
* @return int Het aantal gevonden submappen.
|
||||||
|
*/
|
||||||
private function countDirs(string $dir): int
|
private function countDirs(string $dir): int
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) return 0;
|
if (!is_dir($dir)) return 0;
|
||||||
@@ -207,6 +291,13 @@ class Dashboard
|
|||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berekent de totale bestandsgrootte in bytes van alle bestanden in een map.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $dir Pad naar de map die doorzocht moet worden.
|
||||||
|
* @return int De totale grootte in bytes.
|
||||||
|
*/
|
||||||
private function dirSize(string $dir): int
|
private function dirSize(string $dir): int
|
||||||
{
|
{
|
||||||
if (!is_dir($dir)) return 0;
|
if (!is_dir($dir)) return 0;
|
||||||
@@ -222,6 +313,13 @@ class Dashboard
|
|||||||
return $size;
|
return $size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formateert een byte-grootte naar een leesbare string met eenheid.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param int $bytes Het aantal bytes.
|
||||||
|
* @return string De geformatteerde grootte met eenheid (bijv. '1.5 MB').
|
||||||
|
*/
|
||||||
private function formatSize(int $bytes): string
|
private function formatSize(int $bytes): string
|
||||||
{
|
{
|
||||||
if ($bytes <= 0) return '0 B';
|
if ($bytes <= 0) return '0 B';
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Dashboard Plugin
|
||||||
|
|
||||||
|
Toont site informatie, content statistieken en een plugin overzicht op het admin dashboard.
|
||||||
|
|
||||||
|
## Plugin type
|
||||||
|
|
||||||
|
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
|
||||||
|
|
||||||
|
## Bestandsstructuur
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard/
|
||||||
|
├── Dashboard.php # Hoofd plugin class
|
||||||
|
├── plugin.json # Plugin metadata + instellingen
|
||||||
|
├── README.md # Dit bestand
|
||||||
|
├── assets/ # Optionele CSS/JS (leeg)
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/admin.php # Nederlandse admin labels
|
||||||
|
└── en/admin.php # Engelse admin labels
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functies
|
||||||
|
|
||||||
|
- **Site informatie**: Site titel, standaard taal, CodePress/PHP versie, OS, config status
|
||||||
|
- **Content informatie**: Aantal pagina's, mappen, content grootte
|
||||||
|
- **Plugin overzicht**: Lijst van alle plugins met type (systeem/content) en actief/inactief status
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
|
||||||
|
|
||||||
|
## Taal support
|
||||||
|
|
||||||
|
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('Dashboard')` om labels op te halen. Fallback chain:
|
||||||
|
1. Geselecteerde admin taal
|
||||||
|
2. Plugin `default_language` (`nl`)
|
||||||
|
3. Lege array (sleutel wordt ongewijzigd getoond)
|
||||||
|
|
||||||
|
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'Dashboard',
|
||||||
|
|
||||||
|
// Page header
|
||||||
|
'page_title' => 'Dashboard',
|
||||||
|
|
||||||
|
// Site information card
|
||||||
|
'site_info' => 'Site information',
|
||||||
|
'site_title' => 'Site title',
|
||||||
|
'default_lang' => 'Default language',
|
||||||
|
'cms_version' => 'CodePress version',
|
||||||
|
'php_version' => 'PHP version',
|
||||||
|
'os' => 'Operating system',
|
||||||
|
'config_loaded' => 'Config loaded',
|
||||||
|
'yes' => 'Yes',
|
||||||
|
'no' => 'No',
|
||||||
|
|
||||||
|
// Content information card
|
||||||
|
'content_info' => 'Content information',
|
||||||
|
'pages' => 'Pages',
|
||||||
|
'folders' => 'Folders',
|
||||||
|
'content_size' => 'Content size',
|
||||||
|
|
||||||
|
// Plugins card
|
||||||
|
'plugins' => 'Plugins',
|
||||||
|
'manage' => 'Manage',
|
||||||
|
'plugin_type_system' => 'system',
|
||||||
|
'plugin_type_content' => 'content',
|
||||||
|
'active' => 'Active',
|
||||||
|
'inactive' => 'Inactive',
|
||||||
|
'no_plugins' => 'No plugins found.',
|
||||||
|
];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'Dashboard',
|
||||||
|
|
||||||
|
// Page header
|
||||||
|
'page_title' => 'Dashboard',
|
||||||
|
|
||||||
|
// Site informatie card
|
||||||
|
'site_info' => 'Site informatie',
|
||||||
|
'site_title' => 'Site titel',
|
||||||
|
'default_lang' => 'Standaard taal',
|
||||||
|
'cms_version' => 'CodePress versie',
|
||||||
|
'php_version' => 'PHP versie',
|
||||||
|
'os' => 'Besturingssysteem',
|
||||||
|
'config_loaded' => 'Config geladen',
|
||||||
|
'yes' => 'Ja',
|
||||||
|
'no' => 'Nee',
|
||||||
|
|
||||||
|
// Content informatie card
|
||||||
|
'content_info' => 'Content informatie',
|
||||||
|
'pages' => "Pagina's",
|
||||||
|
'folders' => 'Mappen',
|
||||||
|
'content_size' => 'Content grootte',
|
||||||
|
|
||||||
|
// Plugins card
|
||||||
|
'plugins' => 'Plugins',
|
||||||
|
'manage' => 'Beheren',
|
||||||
|
'plugin_type_system' => 'systeem',
|
||||||
|
'plugin_type_content' => 'content',
|
||||||
|
'active' => 'Actief',
|
||||||
|
'inactive' => 'Inactief',
|
||||||
|
'no_plugins' => 'Geen plugins gevonden.',
|
||||||
|
];
|
||||||
@@ -5,5 +5,7 @@
|
|||||||
"description": "Toont site informatie, content statistieken en plugin overzicht op het dashboard.",
|
"description": "Toont site informatie, content statistieken en plugin overzicht op het dashboard.",
|
||||||
"type": "system",
|
"type": "system",
|
||||||
"essential": true,
|
"essential": true,
|
||||||
"hasConfig": false
|
"hasConfig": false,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": []
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoIPInfo plugin: toont geografische informatie op basis van IP-adres.
|
||||||
|
*
|
||||||
|
* Systeem-plugin die een admin-pagina biedt met het IP-adres van de bezoeker
|
||||||
|
* en het bijbehorende land via de GeoIP-dienst.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class GeoIPInfo
|
class GeoIPInfo
|
||||||
{
|
{
|
||||||
private ?CMSAPI $api = null;
|
/**
|
||||||
|
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginAPIInterface|null
|
||||||
|
*/
|
||||||
|
private ?PluginAPIInterface $api = null;
|
||||||
|
|
||||||
public function setAPI(CMSAPI $api): void
|
/**
|
||||||
|
* Stelt de Plugin-API instantie in.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setAPI(PluginAPIInterface $api): void
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
$this->api = $api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de plugin-configuratie terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> Plugin-configuratie met title en type.
|
||||||
|
*/
|
||||||
public function getConfig(): array
|
public function getConfig(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -18,17 +45,29 @@ class GeoIPInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register admin menu items.
|
* Geeft de admin-menu-items voor deze plugin terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||||
*/
|
*/
|
||||||
public function getAdminMenu(): array
|
public function getAdminMenu(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['label' => 'GeoIP Info', 'route' => 'geoip-info', 'icon' => 'bi-globe2'],
|
[
|
||||||
|
'plugin' => 'GeoIPInfo',
|
||||||
|
'label' => 'GeoIP Info',
|
||||||
|
'label_key' => 'menu_label',
|
||||||
|
'route' => 'geoip-info',
|
||||||
|
'icon' => 'bi-globe2',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register admin routes this plugin handles.
|
* Geeft de admin-routes terug die deze plugin afhandelt.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<int, string> Lijst met admin-route namen.
|
||||||
*/
|
*/
|
||||||
public function getAdminRoutes(): array
|
public function getAdminRoutes(): array
|
||||||
{
|
{
|
||||||
@@ -36,7 +75,13 @@ class GeoIPInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle admin route — render the admin page.
|
* Verwerkt een admin-route en toont de GeoIP-info-pagina.
|
||||||
|
*
|
||||||
|
* Bepaalt het IP-adres van de bezoeker en toont het land en de vlag.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $route De af te handelen admin-route.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function handleAdminRoute(string $route): void
|
public function handleAdminRoute(string $route): void
|
||||||
{
|
{
|
||||||
@@ -46,11 +91,16 @@ class GeoIPInfo
|
|||||||
$country = GeoIP::getCountryName($countryCode);
|
$country = GeoIP::getCountryName($countryCode);
|
||||||
$flag = GeoIP::getCountryFlagEmoji($countryCode);
|
$flag = GeoIP::getCountryFlagEmoji($countryCode);
|
||||||
|
|
||||||
echo '<h2 class="mb-4"><i class="bi bi-globe2"></i> GeoIP Info</h2>';
|
$t = $this->api ? $this->api->getPluginTranslations('GeoIPInfo') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
echo '<h2 class="mb-4"><i class="bi bi-globe2"></i> ' . htmlspecialchars($tr('page_title')) . '</h2>';
|
||||||
echo '<div class="card shadow-sm"><div class="card-body">';
|
echo '<div class="card shadow-sm"><div class="card-body">';
|
||||||
echo '<table class="table table-sm">';
|
echo '<table class="table table-sm">';
|
||||||
echo '<tr><td>IP adres</td><td><code>' . htmlspecialchars($ip) . '</code></td></tr>';
|
echo '<tr><td>' . htmlspecialchars($tr('ip_address')) . '</td><td><code>' . htmlspecialchars($ip) . '</code></td></tr>';
|
||||||
echo '<tr><td>Land</td><td>' . $flag . ' ' . htmlspecialchars($country) . '</td></tr>';
|
echo '<tr><td>' . htmlspecialchars($tr('country')) . '</td><td>' . $flag . ' ' . htmlspecialchars($country) . '</td></tr>';
|
||||||
echo '</table>';
|
echo '</table>';
|
||||||
echo '</div></div>';
|
echo '</div></div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# GeoIPInfo Plugin
|
||||||
|
|
||||||
|
Systeem plugin die GeoIP-informatie (IP adres en land) toont op een admin-pagina.
|
||||||
|
|
||||||
|
## Plugin type
|
||||||
|
|
||||||
|
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
|
||||||
|
|
||||||
|
## Bestandsstructuur
|
||||||
|
|
||||||
|
```
|
||||||
|
GeoIPInfo/
|
||||||
|
├── GeoIPInfo.php # Hoofd plugin class
|
||||||
|
├── plugin.json # Plugin metadata + instellingen
|
||||||
|
├── README.md # Dit bestand
|
||||||
|
├── assets/ # Optionele CSS/JS (leeg)
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/admin.php # Nederlandse admin labels
|
||||||
|
└── en/admin.php # Engelse admin labels
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functies
|
||||||
|
|
||||||
|
- Toont het IP adres van de bezoeker
|
||||||
|
- Toont het land (met vlag-emoji) gebaseerd op GeoIP lookup
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
|
||||||
|
|
||||||
|
## Taal support
|
||||||
|
|
||||||
|
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('GeoIPInfo')` om labels op te halen. Fallback chain:
|
||||||
|
1. Geselecteerde admin taal
|
||||||
|
2. Plugin `default_language` (`nl`)
|
||||||
|
3. Lege array (sleutel wordt ongewijzigd getoond)
|
||||||
|
|
||||||
|
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'GeoIP Info',
|
||||||
|
|
||||||
|
// Page
|
||||||
|
'page_title' => 'GeoIP Info',
|
||||||
|
'ip_address' => 'IP address',
|
||||||
|
'country' => 'Country',
|
||||||
|
];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'GeoIP Info',
|
||||||
|
|
||||||
|
// Page
|
||||||
|
'page_title' => 'GeoIP Info',
|
||||||
|
'ip_address' => 'IP adres',
|
||||||
|
'country' => 'Land',
|
||||||
|
];
|
||||||
@@ -3,5 +3,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "CodePress",
|
"author": "CodePress",
|
||||||
"description": "Systeem plugin voor GeoIP informatie in admin",
|
"description": "Systeem plugin voor GeoIP informatie in admin",
|
||||||
"type": "system"
|
"type": "system",
|
||||||
|
"essential": false,
|
||||||
|
"hasConfig": false,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": []
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTMLBlock plugin: toont context-afhankelijke HTML-blokken in de zijbalk.
|
||||||
|
*
|
||||||
|
* Content-plugin die informatie over de huidige pagina toont,
|
||||||
|
* inclusief taal, homepage-status, bestandsinformatie en snelle navigatie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class HTMLBlock
|
class HTMLBlock
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Plugin-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginAPIInterface|null
|
||||||
|
*/
|
||||||
private ?PluginAPIInterface $api = null;
|
private ?PluginAPIInterface $api = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialiseert de plugin met standaardconfiguratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->config = [
|
$this->config = [
|
||||||
@@ -13,23 +39,45 @@ class HTMLBlock
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stelt de Plugin-API instantie in.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function setAPI(PluginAPIInterface $api): void
|
public function setAPI(PluginAPIInterface $api): void
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
$this->api = $api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genereert de HTML-inhoud voor de zijbalk.
|
||||||
|
*
|
||||||
|
* Toont informatie over de huidige pagina, taal, homepage-status,
|
||||||
|
* bestandsinformatie en een snelle navigatielijst.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return string De opgebouwde HTML voor de zijbalk.
|
||||||
|
*/
|
||||||
public function getSidebarContent(): string
|
public function getSidebarContent(): string
|
||||||
{
|
{
|
||||||
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
|
// Content plugin: translations follow the current content language.
|
||||||
|
$t = $this->api ? $this->api->getPluginTranslations('HTMLBlock') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
|
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : $tr('unknown');
|
||||||
$isHomepage = $this->api ? $this->api->isHomepage() : false;
|
$isHomepage = $this->api ? $this->api->isHomepage() : false;
|
||||||
$currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl';
|
$currentLang = $this->api ? $this->api->getCurrentLanguage() : 'nl';
|
||||||
$currentPath = $_GET['page'] ?? '';
|
$currentPath = $_GET['page'] ?? '';
|
||||||
$currentPath = preg_replace('/\.(md|php|html)$/', '', $currentPath);
|
$currentPath = preg_replace('/\.(md|php|html)$/', '', $currentPath);
|
||||||
|
|
||||||
$content = '
|
$content = '
|
||||||
<p class="mb-2"><strong>Huidige pagina:</strong> ' . htmlspecialchars($currentPage) . '</p>
|
<p class="mb-2"><strong>' . htmlspecialchars($tr('current_page')) . ':</strong> ' . htmlspecialchars($currentPage) . '</p>
|
||||||
<p class="mb-2"><strong>Taal:</strong> ' . strtoupper($currentLang) . '</p>
|
<p class="mb-2"><strong>' . htmlspecialchars($tr('language')) . ':</strong> ' . strtoupper($currentLang) . '</p>
|
||||||
<p class="mb-3"><strong>Homepage:</strong> ' . ($isHomepage ? 'Ja' : 'Nee') . '</p>';
|
<p class="mb-3"><strong>' . htmlspecialchars($tr('homepage')) . ':</strong> ' . htmlspecialchars($isHomepage ? $tr('yes') : $tr('no')) . '</p>';
|
||||||
|
|
||||||
// Add page-specific content
|
// Add page-specific content
|
||||||
if ($this->api) {
|
if ($this->api) {
|
||||||
@@ -38,9 +86,9 @@ class HTMLBlock
|
|||||||
$content .= '
|
$content .= '
|
||||||
<div class="alert alert-info mb-3">
|
<div class="alert alert-info mb-3">
|
||||||
<small>
|
<small>
|
||||||
<strong>Bestandsinfo:</strong><br>
|
<strong>' . htmlspecialchars($tr('file_info')) . ':</strong><br>
|
||||||
Aangemaakt: ' . htmlspecialchars($fileInfo['created']) . '<br>
|
' . htmlspecialchars($tr('created')) . ': ' . htmlspecialchars($fileInfo['created']) . '<br>
|
||||||
Gewijzigd: ' . htmlspecialchars($fileInfo['modified']) . '
|
' . htmlspecialchars($tr('modified')) . ': ' . htmlspecialchars($fileInfo['modified']) . '
|
||||||
</small>
|
</small>
|
||||||
</div>';
|
</div>';
|
||||||
}
|
}
|
||||||
@@ -49,7 +97,7 @@ class HTMLBlock
|
|||||||
$menu = $this->api->getMenu();
|
$menu = $this->api->getMenu();
|
||||||
if (!empty($menu)) {
|
if (!empty($menu)) {
|
||||||
$content .= '
|
$content .= '
|
||||||
<h6>Quick Navigation</h6>
|
<h6>' . htmlspecialchars($tr('quick_navigation')) . '</h6>
|
||||||
<ul class="list-unstyled mb-3">';
|
<ul class="list-unstyled mb-3">';
|
||||||
|
|
||||||
foreach ($menu as $item) {
|
foreach ($menu as $item) {
|
||||||
@@ -83,11 +131,24 @@ class HTMLBlock
|
|||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de plugin-configuratie terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> Plugin-configuratie.
|
||||||
|
*/
|
||||||
public function getConfig(): array
|
public function getConfig(): array
|
||||||
{
|
{
|
||||||
return $this->config;
|
return $this->config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stelt de plugin-configuratie in door deze te mergen met standaardwaarden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param array<string, mixed> $config De configuratie die samengevoegd moet worden.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function setConfig(array $config): void
|
public function setConfig(array $config): void
|
||||||
{
|
{
|
||||||
$this->config = array_merge($this->config, $config);
|
$this->config = array_merge($this->config, $config);
|
||||||
|
|||||||
+29
-90
@@ -1,100 +1,39 @@
|
|||||||
# HTMLBlock Plugin
|
# HTMLBlock Plugin
|
||||||
|
|
||||||
Deze plugin toont een custom HTML blok in de sidebar met pagina-informatie en navigatie.
|
Toont een custom HTML-blok in de sidebar van content-pagina's met pagina-informatie en quick navigation.
|
||||||
|
|
||||||
## Functies
|
## Plugin type
|
||||||
|
|
||||||
- **Pagina informatie**: Toont huidige pagina titel en metadata
|
**Content** plugin. De plugin-output volgt de geselecteerde **content taal** (via `CMSAPI::getPluginTranslations()` met `site.php`).
|
||||||
- **Bestandsinfo**: Aanmaak- en wijzigingsdatums
|
|
||||||
- **Dynamische navigatie**: Genereert quick links uit het menu
|
|
||||||
- **Interactive controls**: Verversen en sidebar toggle
|
|
||||||
- **Responsive**: Werkt op desktop en mobiel
|
|
||||||
|
|
||||||
## Installatie
|
|
||||||
|
|
||||||
1. Kopieer de `HTMLBlock` map naar `plugins/`
|
|
||||||
2. De plugin wordt automatisch geladen
|
|
||||||
|
|
||||||
## Gebruik
|
|
||||||
|
|
||||||
De plugin wordt automatisch in de sidebar geladen en toont:
|
|
||||||
|
|
||||||
### Huidige Pagina Info
|
|
||||||
- Pagina titel
|
|
||||||
- Huidige taal
|
|
||||||
- Homepage status
|
|
||||||
|
|
||||||
### Bestandsinformatie
|
|
||||||
- Aanmaakdatum
|
|
||||||
- Laatste wijziging
|
|
||||||
- Bestandsgrootte
|
|
||||||
|
|
||||||
### Quick Navigation
|
|
||||||
- Dynamische links uit het CMS menu
|
|
||||||
- Automatische URL generatie
|
|
||||||
|
|
||||||
### Interactive Controls
|
|
||||||
- **Ververs Content**: Herlaadt de huidige pagina
|
|
||||||
- **Toggle Sidebar**: Toont/verbergt de sidebar
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
De plugin content kan worden aangepast door de `getSidebarContent()` methode te wijzigen in `HTMLBlock.php`.
|
|
||||||
|
|
||||||
### Voorbeeld Custom Content
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function getSidebarContent(): string
|
|
||||||
{
|
|
||||||
$currentPage = $this->api ? $this->api->getCurrentPageTitle() : 'Onbekend';
|
|
||||||
|
|
||||||
return '
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5>Mijn Custom Block</h5>
|
|
||||||
<p>Huidige pagina: ' . htmlspecialchars($currentPage) . '</p>
|
|
||||||
</div>
|
|
||||||
</div>';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
De plugin maakt gebruik van de CMS API voor:
|
|
||||||
|
|
||||||
- `getCurrentPageTitle()` - Huidige pagina titel
|
|
||||||
- `getCurrentLanguage()` - Huidige taal
|
|
||||||
- `isHomepage()` - Check of homepage
|
|
||||||
- `getCurrentPageFileInfo()` - Bestandsinformatie
|
|
||||||
- `getMenu()` - Menu structuur
|
|
||||||
- `createUrl($page, $lang)` - URL generatie
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
De plugin gebruikt Bootstrap 5 classes:
|
|
||||||
- `card`, `card-header`, `card-body` voor kaarten
|
|
||||||
- `btn`, `btn-outline-primary` voor knoppen
|
|
||||||
- `list-unstyled` voor navigatie
|
|
||||||
|
|
||||||
## JavaScript
|
|
||||||
|
|
||||||
De plugin bevat JavaScript voor:
|
|
||||||
- Pagina verversen
|
|
||||||
- Sidebar toggle functionaliteit
|
|
||||||
- Dynamische content updates
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
De plugin is een goed voorbeeld voor:
|
|
||||||
- API integratie
|
|
||||||
- Dynamic content generatie
|
|
||||||
- User interface components
|
|
||||||
- Responsive design
|
|
||||||
|
|
||||||
## Bestandsstructuur
|
## Bestandsstructuur
|
||||||
|
|
||||||
```
|
```
|
||||||
HTMLBlock/
|
HTMLBlock/
|
||||||
├── HTMLBlock.php # Hoofd plugin bestand
|
├── HTMLBlock.php # Hoofd plugin class
|
||||||
└── README.md # Deze documentatie
|
├── plugin.json # Plugin metadata + instellingen
|
||||||
|
├── README.md # Dit bestand
|
||||||
|
├── assets/ # Optionele CSS/JS (leeg)
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/site.php # Nederlandse front-end labels
|
||||||
|
└── en/site.php # Engelse front-end labels
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Functies
|
||||||
|
|
||||||
|
- **Pagina informatie**: Toont huidige pagina titel en metadata
|
||||||
|
- **Bestandsinfo**: Aanmaak- en wijzigingsdatums
|
||||||
|
- **Quick Navigation**: Genereert quick links uit het CMS menu
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`).
|
||||||
|
|
||||||
|
## Taal support
|
||||||
|
|
||||||
|
De plugin gebruikt `CMSAPI::getPluginTranslations('HTMLBlock')` om labels op te halen. Fallback chain:
|
||||||
|
1. Geselecteerde content taal
|
||||||
|
2. Plugin `default_language` (`nl`)
|
||||||
|
3. Lege array (sleutel wordt ongewijzigd getoond)
|
||||||
|
|
||||||
|
Beschikbare sleutels staan in `language/<lang>/site.php`.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Sidebar card
|
||||||
|
'title' => 'HTML Block',
|
||||||
|
'current_page' => 'Current page',
|
||||||
|
'language' => 'Language',
|
||||||
|
'homepage' => 'Homepage',
|
||||||
|
'yes' => 'Yes',
|
||||||
|
'no' => 'No',
|
||||||
|
'unknown' => 'Unknown',
|
||||||
|
'file_info' => 'File info',
|
||||||
|
'created' => 'Created',
|
||||||
|
'modified' => 'Modified',
|
||||||
|
'quick_navigation' => 'Quick Navigation',
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Sidebar card
|
||||||
|
'title' => 'HTML Block',
|
||||||
|
'current_page' => 'Huidige pagina',
|
||||||
|
'language' => 'Taal',
|
||||||
|
'homepage' => 'Homepage',
|
||||||
|
'yes' => 'Ja',
|
||||||
|
'no' => 'Nee',
|
||||||
|
'unknown' => 'Onbekend',
|
||||||
|
'file_info' => 'Bestandsinfo',
|
||||||
|
'created' => 'Aangemaakt',
|
||||||
|
'modified' => 'Gewijzigd',
|
||||||
|
'quick_navigation' => 'Quick Navigation',
|
||||||
|
];
|
||||||
@@ -5,5 +5,7 @@
|
|||||||
"description": "Toont aangepaste HTML-blokken in de sidebar van content-pagina's.",
|
"description": "Toont aangepaste HTML-blokken in de sidebar van content-pagina's.",
|
||||||
"type": "content",
|
"type": "content",
|
||||||
"essential": false,
|
"essential": false,
|
||||||
"hasConfig": false
|
"hasConfig": false,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": []
|
||||||
}
|
}
|
||||||
+108
-10
@@ -1,14 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs plugin: toont admin- en request-logs in het beheer.
|
||||||
|
*
|
||||||
|
* Systeem-plugin die logbestanden uitleest en in een tabel toont,
|
||||||
|
* met tabbladen voor admin-logs en request-logs.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*/
|
||||||
class Logs
|
class Logs
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Plugin-API instantie voor communicatie met de CMS-core.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var PluginAPIInterface|null
|
||||||
|
*/
|
||||||
private ?PluginAPIInterface $api = null;
|
private ?PluginAPIInterface $api = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stelt de Plugin-API instantie in.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param PluginAPIInterface $api De Plugin-API instantie.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function setAPI(PluginAPIInterface $api): void
|
public function setAPI(PluginAPIInterface $api): void
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
$this->api = $api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de plugin-configuratie terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> Plugin-configuratie met title, viewable en type.
|
||||||
|
*/
|
||||||
public function getConfig(): array
|
public function getConfig(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -18,29 +45,94 @@ class Logs
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de admin-menu-items voor deze plugin terug.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<int, array<string, mixed>> Lijst met admin-menu-item configuraties.
|
||||||
|
*/
|
||||||
public function getAdminMenu(): array
|
public function getAdminMenu(): array
|
||||||
{
|
{
|
||||||
|
$config = $this->getPluginConfig();
|
||||||
|
$requiredRoles = $config['required_roles'] ?? ['bi-manager', 'site-admin', 'admin'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'plugin' => 'Logs',
|
'plugin' => 'Logs',
|
||||||
'route' => 'logs',
|
'route' => 'logs',
|
||||||
'label' => 'Logs',
|
'label' => 'Logs',
|
||||||
|
'label_key' => 'menu_label',
|
||||||
'icon' => 'bi-journal-text',
|
'icon' => 'bi-journal-text',
|
||||||
'section' => 'general',
|
'section' => 'general',
|
||||||
|
'permission' => 'logs',
|
||||||
|
'required_roles' => $requiredRoles,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt de runtime-configuratie van deze plugin op (standaardwaarden plus overrides).
|
||||||
|
*
|
||||||
|
* Leest standaardwaarden uit plugin.json en overschrijft deze met config.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @return array<string, mixed> De samengevoegde plugin-configuratie.
|
||||||
|
*/
|
||||||
|
private function getPluginConfig(): array
|
||||||
|
{
|
||||||
|
$pluginDir = dirname(__DIR__);
|
||||||
|
$pluginJsonFile = $pluginDir . '/plugin.json';
|
||||||
|
$configJsonFile = $pluginDir . '/config.json';
|
||||||
|
|
||||||
|
$defaults = [];
|
||||||
|
if (file_exists($pluginJsonFile)) {
|
||||||
|
$pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? [];
|
||||||
|
foreach ($pluginJson['settings'] ?? [] as $setting) {
|
||||||
|
if (isset($setting['key'])) {
|
||||||
|
$defaults[$setting['key']] = $setting['default'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = [];
|
||||||
|
if (file_exists($configJsonFile)) {
|
||||||
|
$overrides = json_decode(file_get_contents($configJsonFile), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($defaults, $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwerkt een admin-route en toont de logs-pagina.
|
||||||
|
*
|
||||||
|
* Leest de geselecteerde log, parset de regels en toont deze in een tabel
|
||||||
|
* met tabbladen voor admin- en request-logs.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @param string $action De uit te voeren actie.
|
||||||
|
* @return string|null De HTML-uitvoer van de logs-pagina, of null.
|
||||||
|
*/
|
||||||
public function handleAdminRoute(string $action): ?string
|
public function handleAdminRoute(string $action): ?string
|
||||||
{
|
{
|
||||||
|
$config = $this->getPluginConfig();
|
||||||
|
$maxLines = (int)($config['max_lines'] ?? 100);
|
||||||
|
$showAdminTab = $config['show_admin_tab'] ?? true;
|
||||||
|
$showRequestsTab = $config['show_requests_tab'] ?? true;
|
||||||
|
|
||||||
|
// System plugin: translations resolve against the admin language.
|
||||||
|
$t = $this->api ? $this->api->getPluginTranslations('Logs') : [];
|
||||||
|
$tr = function (string $key) use ($t): string {
|
||||||
|
return $t[$key] ?? $key;
|
||||||
|
};
|
||||||
|
|
||||||
$tab = $_GET['tab'] ?? 'admin';
|
$tab = $_GET['tab'] ?? 'admin';
|
||||||
$logDir = dirname(__DIR__, 2) . '/admin/storage/logs';
|
$logDir = ($this->api ? $this->api->getProjectRoot() : dirname(__DIR__, 2)) . '/admin/storage/logs';
|
||||||
$logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log';
|
$logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log';
|
||||||
|
|
||||||
$logs = [];
|
$logs = [];
|
||||||
if (file_exists($logFile)) {
|
if (file_exists($logFile)) {
|
||||||
$lines = file($logFile) ?: [];
|
$lines = file($logFile) ?: [];
|
||||||
$lines = array_slice($lines, -100);
|
$lines = array_slice($lines, -$maxLines);
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
|
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
|
||||||
$logs[] = [
|
$logs[] = [
|
||||||
@@ -56,31 +148,37 @@ class Logs
|
|||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
?>
|
?>
|
||||||
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
|
<h2 class="mb-4"><i class="bi bi-journal-text"></i> <?= htmlspecialchars($tr('page_title')) ?></h2>
|
||||||
|
|
||||||
|
<?php if ($showAdminTab || $showRequestsTab): ?>
|
||||||
<ul class="nav nav-tabs mb-3">
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<?php if ($showAdminTab): ?>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <?= $tab === 'admin' ? 'active' : '' ?>" href="/admin/logs?tab=admin">Admin</a>
|
<a class="nav-link <?= $tab === 'admin' ? 'active' : '' ?>" href="/admin/logs?tab=admin"><?= htmlspecialchars($tr('tab_admin')) ?></a>
|
||||||
</li>
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($showRequestsTab): ?>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <?= $tab === 'requests' ? 'active' : '' ?>" href="/admin/logs?tab=requests">Requests</a>
|
<a class="nav-link <?= $tab === 'requests' ? 'active' : '' ?>" href="/admin/logs?tab=requests"><?= htmlspecialchars($tr('tab_requests')) ?></a>
|
||||||
</li>
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tijd</th>
|
<th><?= htmlspecialchars($tr('col_time')) ?></th>
|
||||||
<th>Level</th>
|
<th><?= htmlspecialchars($tr('col_level')) ?></th>
|
||||||
<th>IP</th>
|
<th><?= htmlspecialchars($tr('col_ip')) ?></th>
|
||||||
<th>Bericht</th>
|
<th><?= htmlspecialchars($tr('col_message')) ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($logs)): ?>
|
<?php if (empty($logs)): ?>
|
||||||
<tr><td colspan="4" class="text-muted text-center py-4">Geen logs gevonden.</td></tr>
|
<tr><td colspan="4" class="text-muted text-center py-4"><?= htmlspecialchars($tr('no_logs')) ?></td></tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($logs as $log): ?>
|
<?php foreach ($logs as $log): ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Logs Plugin
|
||||||
|
|
||||||
|
Toont admin activiteitlogs en front-end request logs met filter tabs op een admin-pagina.
|
||||||
|
|
||||||
|
## Plugin type
|
||||||
|
|
||||||
|
**Systeem** plugin. De plugin-output volgt de geselecteerde **admin taal** (via `AdminPluginAPI::getPluginTranslations()` met `admin.php`).
|
||||||
|
|
||||||
|
## Bestandsstructuur
|
||||||
|
|
||||||
|
```
|
||||||
|
Logs/
|
||||||
|
├── Logs.php # Hoofd plugin class
|
||||||
|
├── plugin.json # Plugin metadata + instellingen
|
||||||
|
├── README.md # Dit bestand
|
||||||
|
├── assets/ # Optionele CSS/JS (leeg)
|
||||||
|
└── language/ # Vertalingen
|
||||||
|
├── nl/admin.php # Nederlandse admin labels
|
||||||
|
└── en/admin.php # Engelse admin labels
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functies
|
||||||
|
|
||||||
|
- **Admin tab**: Toont admin activiteitlogs (uit `admin/storage/logs/admin.log`)
|
||||||
|
- **Requests tab**: Toont front-end request logs (uit `admin/storage/logs/requests.log`)
|
||||||
|
- Tabbladen kunnen via de instellingen in/uitgeschakeld worden
|
||||||
|
- Aantal getoonde regels is instelbaar (meest recente eerst)
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
Deze plugin heeft instelbare configuratie (`hasConfig: true`). Instellingen worden gedefinieerd in `plugin.json` onder `settings` en overschreven via `config.json`.
|
||||||
|
|
||||||
|
| Sleutel | Type | Standaard | Beschrijving |
|
||||||
|
|---------|------|-----------|--------------|
|
||||||
|
| `required_roles` | multi-select | `bi-manager, site-admin, admin` | Rollen die deze plugin mogen zien |
|
||||||
|
| `max_lines` | number | `100` | Aantal logregels dat getoond wordt (meest recente eerst) |
|
||||||
|
| `show_admin_tab` | checkbox | `true` | Schakel de admin activiteit-log tab in of uit |
|
||||||
|
| `show_requests_tab` | checkbox | `true` | Schakel de front-end request-log tab in of uit |
|
||||||
|
|
||||||
|
De label- en help-teksten voor instellingen worden vertaald via `label_key`/`help_key` (verwijst naar sleutels in `language/<lang>/admin.php`).
|
||||||
|
|
||||||
|
## Taal support
|
||||||
|
|
||||||
|
De plugin gebruikt `AdminPluginAPI::getPluginTranslations('Logs')` om labels op te halen. Fallback chain:
|
||||||
|
1. Geselecteerde admin taal
|
||||||
|
2. Plugin `default_language` (`nl`)
|
||||||
|
3. Lege array (sleutel wordt ongewijzigd getoond)
|
||||||
|
|
||||||
|
Beschikbare sleutels staan in `language/<lang>/admin.php`. Het admin-menu label wordt vertaald via `label_key: 'menu_label'` in `getAdminMenu()`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'Logs',
|
||||||
|
|
||||||
|
// Page
|
||||||
|
'page_title' => 'Logs',
|
||||||
|
'tab_admin' => 'Admin',
|
||||||
|
'tab_requests' => 'Requests',
|
||||||
|
'col_time' => 'Time',
|
||||||
|
'col_level' => 'Level',
|
||||||
|
'col_ip' => 'IP',
|
||||||
|
'col_message' => 'Message',
|
||||||
|
'no_logs' => 'No logs found.',
|
||||||
|
'analytics_disabled' => 'Analytics is disabled.',
|
||||||
|
|
||||||
|
// Settings (plugin.json label_key/help_key)
|
||||||
|
'setting_required_roles' => 'Roles allowed to view this plugin',
|
||||||
|
'setting_required_roles_help' => 'Determines which roles can access the logs page in the admin panel. Admin always has access.',
|
||||||
|
'role_options' => [
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'content-manager' => 'Content Manager',
|
||||||
|
'bi-manager' => 'BI Manager',
|
||||||
|
'site-admin' => 'Site Admin',
|
||||||
|
],
|
||||||
|
'setting_max_lines' => 'Maximum number of lines',
|
||||||
|
'setting_max_lines_help' => 'Number of log lines shown (most recent first).',
|
||||||
|
'setting_show_admin_tab' => 'Show admin log tab',
|
||||||
|
'setting_show_admin_tab_help' => 'Enable or disable the admin activity log tab.',
|
||||||
|
'setting_show_requests_tab' => 'Show request log tab',
|
||||||
|
'setting_show_requests_tab_help' => 'Enable or disable the front-end request log tab.',
|
||||||
|
];
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// Menu
|
||||||
|
'menu_label' => 'Logs',
|
||||||
|
|
||||||
|
// Page
|
||||||
|
'page_title' => 'Logs',
|
||||||
|
'tab_admin' => 'Admin',
|
||||||
|
'tab_requests' => 'Requests',
|
||||||
|
'col_time' => 'Tijd',
|
||||||
|
'col_level' => 'Level',
|
||||||
|
'col_ip' => 'IP',
|
||||||
|
'col_message' => 'Bericht',
|
||||||
|
'no_logs' => 'Geen logs gevonden.',
|
||||||
|
'analytics_disabled' => 'Analytics is uitgeschakeld.',
|
||||||
|
|
||||||
|
// Settings (plugin.json label_key/help_key)
|
||||||
|
'setting_required_roles' => 'Rollen die deze plugin mogen zien',
|
||||||
|
'setting_required_roles_help' => 'Bepaalt welke rollen toegang hebben tot de logs-pagina in het admin-paneel. Admin heeft altijd toegang.',
|
||||||
|
'role_options' => [
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'content-manager' => 'Content Beheerder',
|
||||||
|
'bi-manager' => 'BI Beheerder',
|
||||||
|
'site-admin' => 'Site Admin',
|
||||||
|
],
|
||||||
|
'setting_max_lines' => 'Maximaal aantal regels',
|
||||||
|
'setting_max_lines_help' => 'Aantal logregels dat getoond wordt (meest recente eerst).',
|
||||||
|
'setting_show_admin_tab' => 'Toon admin-log tab',
|
||||||
|
'setting_show_admin_tab_help' => 'Schakel de admin activiteit-log tab in of uit.',
|
||||||
|
'setting_show_requests_tab' => 'Toon request-log tab',
|
||||||
|
'setting_show_requests_tab_help' => 'Schakel de front-end request-log tab in of uit.',
|
||||||
|
];
|
||||||
@@ -5,5 +5,43 @@
|
|||||||
"description": "Toont admin activiteitlogs en front-end request logs met filter tabs.",
|
"description": "Toont admin activiteitlogs en front-end request logs met filter tabs.",
|
||||||
"type": "system",
|
"type": "system",
|
||||||
"essential": false,
|
"essential": false,
|
||||||
"hasConfig": true
|
"hasConfig": true,
|
||||||
|
"default_language": "nl",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "required_roles",
|
||||||
|
"label_key": "setting_required_roles",
|
||||||
|
"help_key": "setting_required_roles_help",
|
||||||
|
"option_label_key": "role_options",
|
||||||
|
"type": "multi-select",
|
||||||
|
"default": ["bi-manager", "site-admin", "admin"],
|
||||||
|
"options": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"content-manager": "Content Beheerder",
|
||||||
|
"bi-manager": "BI Beheerder",
|
||||||
|
"site-admin": "Site Admin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max_lines",
|
||||||
|
"label_key": "setting_max_lines",
|
||||||
|
"help_key": "setting_max_lines_help",
|
||||||
|
"type": "number",
|
||||||
|
"default": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_admin_tab",
|
||||||
|
"label_key": "setting_show_admin_tab",
|
||||||
|
"help_key": "setting_show_admin_tab_help",
|
||||||
|
"type": "checkbox",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "show_requests_tab",
|
||||||
|
"label_key": "setting_show_requests_tab",
|
||||||
|
"help_key": "setting_show_requests_tab_help",
|
||||||
|
"type": "checkbox",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user