7 Commits
Author SHA1 Message Date
E.Noorlander 6485f693dc v2.6.3 (Lyra): Content multi-type handling, getAllPages() structuur, . verberg-prefix
- Content bestanden met dezelfde naam maar ander type (md/php/html) worden
  correct geserveerd: URL met extensie opent dat bestand, URL zonder extensie
  valt terug op md > php > html (resolveContentByType helper)
- Admin content editor accepteert bestanden met dezelfde naam (ander type);
  preview-knop linkt per extensie
- Frontend navigatie/directory listing/search tonen elk bestandstype apart
- getAllPages() array structuur gewijzigd naar list van
  ['path','title','type'] met type 'md'/'php'/'html'/'folder'
- Verberg-prefix logica: _ is geen verberg-prefix meer, alleen . (en -);
  admin toont wél alle . bestanden/mappen
- ContentAPI getPage()/pageExists() respecteren expliciete extensie
- Handleiding content-api.md (NL+EN) herschreven
- File-tree unificatie: _file-tree.twig + _editor-styles.twig includes
- Versie verhoogd naar 2.6.3
2026-08-20 16:45:53 +00:00
E.Noorlander 97c4d52c78 Fix: /admin/content is nu boom-editor + image-grootte knop + custom syntax
- /admin/content is nu de boom-editor (plugin-file-tree structuur),
  consistent met plugin- en thema-editors. Oude tabelweergave verhuisd
  naar /admin/content-list, bereikbaar via 'Boom weergave'/'Lijst
  weergave' knoppen. /admin/content-files redirect naar /admin/content.
- Image-grootte knop in markdown editor-toolbar: selecteer ![alt](url),
  klik knop, breedte/hoogte dialog, {:width=... height=...} syntax
  toevoegen/vervangen. Bestaande waarden worden in prompts getoond.
- Custom-grootte syntax {:width=...} nu ook correct herschreven naar
  /-media/ endpoint (was alleen gewone ![alt](url) herschreven).
- Handleidingen bijgewerkt (content-beheer.md NL+EN: boom/lijst
  weergave uitleg + image-grootte + images in content sectie)
- Release notes v2.6.2 bijgewerkt
- Pentest 30/30, WCAG 25/25
2026-08-19 12:15:56 +00:00
E.Noorlander e926a3a40d Fix: _ mappen verborgen in frontend + image URL rewriting naar /-media/
- Bug: mappen met _ prefix (zoals _drafts, _data) werden zichtbaar in
  frontend navigatie omdat scanDirectory() alleen . en - oversloeg.
  scanDirectory(), searchInDirectory() en scanForPageTitles() skippen
  nu ook _ prefix (consistente filtering).
- Bug: images in markdown niet weergegeven. ![alt](test.svg) werd
  <img src=test.svg> (relatief -> 404) en ![alt](/content/test.svg)
  werd /content/test.svg (buiten webroot -> 404). processContent()
  herschrijft nu lokale image/link URLs naar /-media/ endpoint.
  Externe URLs (http(s)://), /-media/, /-assets/, /themes/, /plugins/,
  /admin/, data: en mailto: worden ongewijzigd gelaten.
- Release notes v2.6.2 bijgewerkt met beide bugfixes
- Pentest 30/30, WCAG 25/25
2026-08-19 10:52:07 +00:00
E.Noorlander 7249aca885 v2.6.2 (Lyra): Thema editor, content editor, unified scanEditorFiles
- 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
- Content editor (content consistent met plugins en thema's):
  bestandsbrowser zijbalk naast bestaande lijst weergave, nieuw
  bestand/map, uploaden, verwijderen, verplaatsen, mappen beheer,
  layout/plugins selectie, git/backup integratie in editor zijbalk
- Uniformiteit: scanEditorFiles() unified scanner
  (scanPluginFiles/scanThemeFiles/scanContentFiles als dunne wrappers)
- Media invoegen in editor: ?theme= scope voor media-list endpoint
- Bug fix: thema-naam niet overgenomen bij kopiëren - title in
  theme.json wordt overschreven met nieuwe themanaam
- Handleidingen bijgewerkt (thema-beheer.md + content-beheer.md NL/EN)
- Release notes: docs/release-notes/v2.6.2.md
- Pentest 30/30, WCAG 25/25
2026-08-19 10:44:32 +00:00
E.Noorlander 5edc929c13 v2.6.1d (Lyra): Plugin i18n, plugin editor vernieuwd, media invoegen
Plugin internationalisatie: plugins hebben eigen language/ mappen. Systeem
plugins volgen admin taal (admin.php), content plugins volgen content taal
(site.php). Fallback chain: geselecteerd -> plugin default_language -> CMS
default. PluginManager/AdminPluginAPI/CMSAPI uitgebreid met
getPluginTranslations()/t(). plugin.json settings ondersteunen
label_key/help_key/option_label_key.

Plugin uniformiteit: alle 6 plugins hebben uniforme structuur (README.md,
assets/.gitkeep, language/nl|en/). plugins/README.md herschreven.
guide plugin-development.md (NL+EN) volledig herschreven.

Plugin editor vernieuwd: geneste bestandsbrowser zijbalk, nieuw bestand
aanmaken, uploaden naar assets/, verwijderen en verplaatsen. Nieuwe routes:
plugins-file-upload, plugins-file-delete, plugins-file-move. Path-traversal
bescherming + protected plugins geblokkeerd.

Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare
_media-modal.twig include. Plugin-context scant assets/ map. editor-toolbar.js
modeMap uitgebreid voor css/scss/js/json.

Plugin overzicht knoppen: alleen iconen met title/aria-label.

Tests: pentest 30/30, WCAG 2.1 AA 25/25.
2026-08-18 13:49:52 +00:00
root 10c72de859 v2.6.1c (Lyra): Admin gebruikersbeheer opnieuw ontworpen + CLI reset
Nieuwe features:
- Admin gebruikerslijst met zoeken/filter op gebruikersnaam en rol
- Profiel bewerken pagina met wachtwoord wijzigen en rol wijzigen
- Nieuwe gebruiker aanmaken via aparte pagina
- CLI commando cli/reset-admin-password.php voor admin wachtword reset + lockout reset

Architectuur:
- /admin/users: lijst met zoekveld, rol filter, bewerk/verwijder acties
- /admin/users-edit?user=<naam>: profiel, wachtwoord, rol, verwijderen
- /admin/users-new: nieuwe gebruiker aanmaken
- AdminAuth::clearLockout() public methode voor CLI gebruik

Documentatie:
- guide/nl/en/admin-beheerder/gebruikers.md herschreven
- 15 nieuwe admin vertaalkeys in NL/EN/DE
- Release notes: docs/release-notes/v2.6.1c.md

Tests:
- Pentest: 30/30 geslaagd, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 geslaagd, 100% compliance
2026-08-17 15:10:40 +00:00
root 2d9ffaa942 v2.6.1 (Lyra): Welcome page, 404 handling, installatie docs, opschoning
Nieuwe features:
- Welkomstpagina bij lege content-map (nieuwe installatie detectie)
- 404-afhandeling binnen actieve theme via admin/static/404.html
- HTTP 404 status bij onbekende pagina's en missende taalprefix

Opschoning:
- Verwijderd: package.json, src/scss/, root .htaccess, themes/demo/
- Verwijderde vendor packages: php-mqtt/client, mustache/mustache
- AGENTS.md samengevoegd naar root, development/AGENTS.md verwijderd
- .gitignore opgeschoond (NPM/node_modules/.sass-cache verwijderd)

Documentatie:
- Installatie instructies toegevoegd aan README (Apache2/Nginx/PHP/composer)
- README en guide versie referenties bijgewerkt naar 2.6.1
- Release notes: docs/release-notes/v2.6.1.md

Tests:
- Pentest: 30/30 geslaagd, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 geslaagd, 100% compliance
- Test scripts gebruiken Apache-URL i.p.v. localhost:8080
2026-08-17 14:48:46 +00:00
137 changed files with 8957 additions and 4924 deletions
-5
View File
@@ -1,7 +1,3 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs # Build outputs
*.log *.log
.DS_Store .DS_Store
@@ -15,7 +11,6 @@ Thumbs.db
# Cache & Storage # Cache & Storage
.cache/ .cache/
.sass-cache/
admin/storage/cache/ admin/storage/cache/
admin/storage/geoip/ admin/storage/geoip/
admin/storage/stats.json admin/storage/stats.json
-53
View File
@@ -1,53 +0,0 @@
# Disable directory listing globally
Options -Indexes
# Security - Block access to entire application
<Files ~ "^\.">
Order allow,deny
Deny from all
</Files>
<FilesMatch "\.(php|ini|log|conf|config|md|map)$">
Order allow,deny
Deny from all
</FilesMatch>
# Block access to backup files
<FilesMatch "\.(backup|bak|old|orig|swp|save)$">
Order allow,deny
Deny from all
</FilesMatch>
# Block access to all application files
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Directory protection - Block all access
<Directory />
Order allow,deny
Deny from all
</Directory>
# Only allow access to public directory
<Directory "public">
Order allow,deny
Allow from all
Require all granted
</Directory>
# Set default directory to public
DirectoryIndex public/index.php
# Redirect root to public directory
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Redirect root to public
RewriteRule ^$ public/ [L]
# Redirect all other requests to public
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ public/$1 [L]
</IfModule>
-115
View File
@@ -1,115 +0,0 @@
# Agent Instructions for CodePress CMS
## AI Model
- **Huidig model**: `claude-opus-4-6` (OpenCode / `opencode/claude-opus-4-6`)
- Sessie gestart: 16 feb 2026
## Build & Run
- **Run Server**: `php -S localhost:8080 cms/router.php` (router nodig voor clean URLs)
- **Lint PHP**: `find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;`
- **Dependencies**: Composer vereist voor CommonMark, Twig en scssphp. Geen NPM.
- **Admin Console**: Toegankelijk op `/admin.php` (standaard login: `admin` / `admin`)
## Project Structuur
```
codepress/
├── cms/ # Core CMS engine
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Hoofd CMS class
│ │ │ ├── ThemeManager.php # Thema-resolver + Twig render + SCSS compile
│ │ │ └── Logger.php # Logging systeem
│ │ ├── plugin/
│ │ │ ├── PluginManager.php # Plugin loader
│ │ │ └── CMSAPI.php # API voor plugins
│ │ ├── config.php # Config loader (leest config.json)
│ │ └── index.php # Bootstrap (autoloader, requires)
│ └── router.php # PHP dev server router (serveert ook /themes/)
├── language/ # Taalbestanden (nl/, en/, de/ — elk met site.php + admin.php)
├── themes/ # Dynamische thema's (volledig zelfstandig)
│ ├── default/ # Standaard thema
│ │ ├── theme.json # { title, default_layout, layout→.twig mapping, kleuren }
│ │ ├── base.twig # Hoofd layout (head, header, nav, footer)
│ │ ├── full_content.twig # Layout: volledige breedte
│ │ ├── left_sidebar.twig # Layout: sidebar links
│ │ ├── right_sidebar.twig # Layout: sidebar rechts
│ │ ├── custom1.twig # Layout: custom
│ │ ├── guide.twig # Layout: handleiding
│ │ ├── partials/ # header.twig, navigation.twig, footer.twig
│ │ ├── assets/scss/theme.scss # SCSS bron (runtime gecompileerd)
│ │ └── assets/js/ # app.js, bootstrap.bundle.min.js
│ ├── demo/ # Demo thema (zelfde structuur, andere look)
├── admin/ # Admin paneel
│ ├── config/
│ │ ├── app.php # Admin app configuratie
│ │ ├── admin.json # Gebruikers & security (file-based, gitignored)
│ │ └── admin.json.example # Voorbeeld met placeholder-wachtwoord
│ ├── src/
│ │ └── AdminAuth.php # Authenticatie (sessies, bcrypt, CSRF, lockout)
│ ├── theme/default/views/ # Twig templates
│ │ ├── login.twig # Login pagina
│ │ ├── layouts/admin.twig # Admin layout met sidebar
│ │ └── pages/ # dashboard, content, content-edit, config, plugins, theme, users, statistics, logs, security, update, guide, media, etc.
│ └── storage/logs/ # Admin logs (gedeeld met front-end)
├── cli/ # CLI scripts & tests
│ └── test/
│ ├── accessibility.sh # WCAG 2.1 AA test suite
│ ├── enhanced-suite.sh # Enhanced test suite
│ ├── functional/ # Functionele testen
│ └── pentest/ # Penetratietesten
├── plugins/ # CMS plugins
│ ├── HTMLBlock/
│ └── Navigation/
├── public/ # Web root
│ ├── assets/css/js/
│ ├── index.php # Website entry point
│ └── admin.php # Admin entry point + router
├── content/ # Content bestanden
├── guide/ # Handleidingen (nl/en)
├── docs/ # Documentatie
├── config.json # Site configuratie
└── AGENTS.md # Dit bestand
```
## Code Style & Conventions
- **PHP Standards**: Follow PSR-12. Use 4 spaces for indentation.
- **Naming**: Classes `PascalCase` (e.g., `CodePressCMS`), methods `camelCase` (e.g., `renderMenu`), variables `camelCase`, config keys `snake_case`.
- **Architecture**:
- Core CMS logic in `cms/core/class/CodePressCMS.php`
- Bootstrap/requires in `cms/core/index.php`
- Configuration loaded from `config.json` via `cms/core/config.php`
- Public website entry point: `public/index.php`
- Admin entry point + routing: `public/admin.php`
- Admin authenticatie: `admin/src/AdminAuth.php`
- **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
- **Templating**: Twig templates in `themes/<naam>/`. `ThemeManager` rendert via Twig en compileert `assets/scss/theme.scss` runtime naar `public/themes/<naam>/theme.css`. Layout gekozen via frontmatter `layout:` key; onbekende layouts vallen terug op `default_layout` in `theme.json`.
- **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs.
- **Security**:
- Always use `htmlspecialchars()` for outputting user/content data
- Use `realpath()` + prefix-check for path traversal prevention
- Admin forms require CSRF tokens via `AdminAuth::verifyCsrf()`
- Passwords stored as bcrypt hashes in `admin.json`
- **Git**: `main` is the clean CMS core. `development` is de actieve development branch. `e.noorlander` bevat persoonlijke content. Niet mixen.
## Admin Console
- **File-based**: Geen database. Gebruikers opgeslagen in `admin/config/admin.json`
- **Routing**: Via `?route=` parameter in `public/admin.php`
- **Routes**: `login`, `logout`, `dashboard`, `content`, `content-edit`, `content-new`, `content-delete`, `content-dir-create`, `content-dir-rename`, `content-dir-delete`, `content-move`, `config`, `plugins`, `plugins-new`, `plugins-edit`, `plugins-config`, `plugins-toggle`, `plugins-delete`, `theme`, `theme-new`, `users`, `security`, `statistics`, `logs`, `guide`, `media`, `update`
- **Auth**: Session-based. `AdminAuth` class handelt login, logout, CSRF, brute-force lockout af
- **Templates**: Twig templates in `admin/theme/default/views/`. Layout in `layouts/admin.twig`
## Important: Title vs File/Directory Name Logic
- **CRITICAL**: When user asks for "title" corrections, they usually mean **FILE/DIRECTORY NAME WITHOUT LANGUAGE PREFIX AND EXTENSIONS**, not the HTML title from content!
- **Examples**:
- `nl.test.md` → display as "Test" (not content title)
- `nl.test/` directory → display as "Test" (not H1 content)
- `en.php-testen` → display as "Php Testen" (not "ICT")
- **Method**: Use `formatDisplayName()` to process file/directory names correctly
- **Priority**: Directory names take precedence over file names when both exist
- **Language prefixes**: Dynamisch verwijderd op basis van beschikbare talen via `getAvailableLanguages()`
## Bekende aandachtspunten
- LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze.
- Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features.
- `vendor/` map bevat Composer dependencies (CommonMark, Twig, scssphp, GeoIP2). Niet handmatig wijzigen.
- `admin/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.
+132 -4
View File
@@ -4,13 +4,13 @@
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.5.1 | **License:** AGPL v3 / Commercial **Version:** 2.6.3 | **License:** AGPL v3 / Commercial
## ✨ Features ## ✨ Features
- 📝 **Multi-format Content** - Markdown, PHP and HTML files - 📝 **Multi-format Content** - Markdown, PHP and HTML files
- 🧭 **Dynamic Navigation** - Automatic menu generation - 🧭 **Dynamic Navigation** - Automatic menu generation
- 🌍 **Multi-language** - NL/EN/DE/FR support - 🌍 **Multi-language** - NL/EN/DE support
- 🔍 **Search** - Full-text search - 🔍 **Search** - Full-text search
- 📱 **Responsive** - Bootstrap 5 themes - 📱 **Responsive** - Bootstrap 5 themes
- 🔒 **Security** - 100/100 pentest score - 🔒 **Security** - 100/100 pentest score
@@ -27,13 +27,141 @@ A lightweight, file-based content management system built with PHP (≥8.0).
# Install dependencies # Install dependencies
composer install composer install
# Start server with router for clean URLs # Start server with router for clean URLs (local only)
php -S localhost:8080 cms/router.php php -S localhost:8080 cms/router.php
``` ```
**Website:** `http://localhost:8080` **Website:** `http://localhost:8080`
**Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`) **Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`)
## 📦 Installation
### Requirements
- **PHP** ≥ 8.0 with extensions: `json`, `mbstring`
- **Composer** (PHP dependency manager)
- Web server: **Apache 2.4+** with `mod_rewrite` or **Nginx** with PHP-FPM
- Optional: `opcache` (recommended for performance), `git` (for content versioning), `zip` extension (for ZIP backup/restore)
### Step 1 — Code and dependencies
```bash
git clone <repository-url> codepress
cd codepress
composer install
```
### Step 2 — Configuration
```bash
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`).
### Step 3a — Apache 2.4+
The webroot is the `public/` directory. Example vhost (`/etc/apache2/sites-available/codepress.conf`):
```apache
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/codepress/public
<Directory /var/www/codepress/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/codepress_error.log
CustomLog ${APACHE_LOG_DIR}/codepress_access.log combined
</VirtualHost>
```
Required Apache modules:
```bash
sudo a2enmod rewrite headers
sudo systemctl restart apache2
```
- `mod_rewrite` — for clean URLs (`/nl/page`) and asset-serving
- `mod_headers` — for security headers
- `AllowOverride All` — so the `.htaccess` in `public/` is applied
### Step 3b — Nginx
Example server block (`/etc/nginx/sites-available/codepress`):
```nginx
server {
listen 80;
server_name example.com;
root /var/www/codepress/public;
index index.php;
# Clean URLs: language-prefixed pages
location ~ ^/(nl|en|de)(/(.+))?$ {
try_files $uri /index.php?lang=$1&page=$2;
}
# Admin routes
location /admin {
try_files $uri /admin.php?$args;
}
# Asset-serving via asset.php (themes/plugins/admin outside webroot)
location ~ ^/(themes|plugins)/([^/]+)/assets/(.+)$ {
try_files $uri /asset.php;
}
location ~ ^/admin/assets/(.+)$ {
try_files $uri /asset.php;
}
# PHP via FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Security: block access to sensitive directories
location ~ ^/(content|cms|admin/src|admin/config|admin/storage|var|vendor)/ {
deny all;
return 403;
}
location ~ /\.(git|htaccess) {
deny all;
}
}
```
**Note:** Nginx does not use `.htaccess`. Security headers must be set in the Nginx config:
```nginx
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';";
```
### Step 4 — Directory permissions
Make sure the web server has write access to the runtime directories:
```bash
chown -R www-data:www-data var/ admin/storage/ content/
chmod -R 755 .
```
### Step 5 — Test
Open the website in your browser. With an empty content directory you'll see a welcome page. The admin console is available at `/admin` (login `admin`/`admin`).
## 📚 Documentation ## 📚 Documentation
See **[guide/](guide/)** for extensive documentation per role: See **[guide/](guide/)** for extensive documentation per role:
@@ -68,6 +196,7 @@ codepress/
├── admin/ # Admin console ├── admin/ # Admin console
│ ├── config/ # Admin configuration (admin.json) │ ├── config/ # Admin configuration (admin.json)
│ ├── src/AdminAuth.php # Authentication, roles, permissions │ ├── src/AdminAuth.php # Authentication, roles, permissions
│ ├── static/ # Static files (404.html)
│ ├── storage/ # Logs, cache, geoip │ ├── storage/ # Logs, cache, geoip
│ └── theme/default/ # Admin theme │ └── theme/default/ # Admin theme
│ ├── assets/ # CSS, JS, fonts, codemirror │ ├── assets/ # CSS, JS, fonts, codemirror
@@ -80,7 +209,6 @@ codepress/
│ │ ├── *.twig # Layout templates │ │ ├── *.twig # Layout templates
│ │ ├── partials/ # Header, navigation, footer │ │ ├── partials/ # Header, navigation, footer
│ │ └── assets/ # SCSS, CSS, JS, img │ │ └── assets/ # SCSS, CSS, JS, img
│ └── demo/ # Demo theme
├── plugins/ # Plugins ├── plugins/ # Plugins
│ ├── HTMLBlock/ # Example sidebar plugin │ ├── HTMLBlock/ # Example sidebar plugin
│ └── Navigation/ # Essential navigation plugin (protected) │ └── Navigation/ # Essential navigation plugin (protected)
+162 -12
View File
@@ -4,13 +4,13 @@
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.5.0 | **Licentie:** AGPL v3 / Commercial **Versie:** 2.6.3 | **Licentie:** AGPL v3 / Commercial
## ✨ Features ## ✨ Features
- 📝 **Multi-format Content** - Markdown, PHP en HTML bestanden - 📝 **Multi-format Content** - Markdown, PHP en HTML bestanden
- 🧭 **Dynamic Navigation** - Automatische menu generatie - 🧭 **Dynamic Navigation** - Automatische menu generatie
- 🌍 **Multi-language** - NL/EN/DE/FR ondersteuning - 🌍 **Multi-language** - NL/EN/DE ondersteuning
- 🔍 **Search** - Volledige tekst zoekfunctie - 🔍 **Search** - Volledige tekst zoekfunctie
- 📱 **Responsive** - Bootstrap 5 thema's - 📱 **Responsive** - Bootstrap 5 thema's
- 🔒 **Security** - 100/100 pentest score - 🔒 **Security** - 100/100 pentest score
@@ -27,13 +27,141 @@ Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0)
# Installeer dependencies # Installeer dependencies
composer install composer install
# Start server met router voor schone URLs # Start server met router voor schone URLs (alleen lokaal)
php -S localhost:8080 cms/router.php php -S localhost:8080 cms/router.php
``` ```
**Website:** `http://localhost:8080` **Website:** `http://localhost:8080`
**Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`) **Admin:** `http://localhost:8080/admin` (login: `admin` / `admin`)
## 📦 Installatie
### Vereisten
- **PHP** ≥ 8.0 met extensies: `json`, `mbstring`
- **Composer** (PHP dependency manager)
- Webserver: **Apache 2.4+** met `mod_rewrite` of **Nginx** met PHP-FPM
- Optioneel: `opcache` (aanbevolen voor performance), `git` (voor content versioning), `zip` extensie (voor ZIP backup/restore)
### Stap 1 — Code en dependencies
```bash
git clone <repository-url> codepress
cd codepress
composer install
```
### Stap 2 — Configuratie
```bash
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`).
### Stap 3a — Apache 2.4+
De webroot is de `public/` map. Voorbeeld vhost (`/etc/apache2/sites-available/codepress.conf`):
```apache
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/codepress/public
<Directory /var/www/codepress/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/codepress_error.log
CustomLog ${APACHE_LOG_DIR}/codepress_access.log combined
</VirtualHost>
```
Benodigde Apache modules:
```bash
sudo a2enmod rewrite headers
sudo systemctl restart apache2
```
- `mod_rewrite` — voor clean URLs (`/nl/pagina`) en asset-serving
- `mod_headers` — voor security headers
- `AllowOverride All` — zodat `.htaccess` in `public/` wordt toegepast
### Stap 3b — Nginx
Voorbeeld server block (`/etc/nginx/sites-available/codepress`):
```nginx
server {
listen 80;
server_name example.com;
root /var/www/codepress/public;
index index.php;
# Clean URLs: taal-prefixed pagina's
location ~ ^/(nl|en|de)(/(.+))?$ {
try_files $uri /index.php?lang=$1&page=$2;
}
# Admin routes
location /admin {
try_files $uri /admin.php?$args;
}
# Asset-serving via asset.php (themes/plugins/admin buiten webroot)
location ~ ^/(themes|plugins)/([^/]+)/assets/(.+)$ {
try_files $uri /asset.php;
}
location ~ ^/admin/assets/(.+)$ {
try_files $uri /asset.php;
}
# PHP via FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Beveiliging: blokkeer toegang tot gevoelige mappen
location ~ ^/(content|cms|admin/src|admin/config|admin/storage|var|vendor)/ {
deny all;
return 403;
}
location ~ /\.(git|htaccess) {
deny all;
}
}
```
**Let op:** Nginx gebruikt geen `.htaccess`. De security headers moeten in de Nginx config worden gezet:
```nginx
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';";
```
### Stap 4 — Mappen rechten
Zorg dat de webserver schrijfrechten heeft op de runtime mappen:
```bash
chown -R www-data:www-data var/ admin/storage/ content/
chmod -R 755 .
```
### Stap 5 — Test
Open de website in je browser. Bij een lege content-map zie je een welkomstpagina. De admin console is bereikbaar via `/admin` (login `admin`/`admin`).
## 📚 Handleidingen ## 📚 Handleidingen
Zie **[guide/](guide/)** voor uitgebreide documentatie per rol: Zie **[guide/](guide/)** voor uitgebreide documentatie per rol:
@@ -68,6 +196,7 @@ codepress/
├── admin/ # Admin console ├── admin/ # Admin console
│ ├── config/ # Admin configuratie (admin.json) │ ├── config/ # Admin configuratie (admin.json)
│ ├── src/AdminAuth.php # Authenticatie, rollen, permissies │ ├── src/AdminAuth.php # Authenticatie, rollen, permissies
│ ├── static/ # Statische bestanden (404.html)
│ ├── storage/ # Logs, cache, geoip │ ├── storage/ # Logs, cache, geoip
│ └── theme/default/ # Admin thema │ └── theme/default/ # Admin thema
│ ├── assets/ # CSS, JS, fonts, codemirror │ ├── assets/ # CSS, JS, fonts, codemirror
@@ -80,14 +209,16 @@ codepress/
│ │ ├── *.twig # Layout templates │ │ ├── *.twig # Layout templates
│ │ ├── partials/ # Header, navigation, footer │ │ ├── partials/ # Header, navigation, footer
│ │ └── assets/ # SCSS, CSS, JS, img │ │ └── assets/ # SCSS, CSS, JS, img
│ └── demo/ # Demo thema
├── 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
@@ -153,14 +284,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.
+84 -10
View File
@@ -1,15 +1,19 @@
# TODO # TODO
## Voor elke versie verhoging. Deze nooit weghalen. ## bug
- [ ] Pentest controles uitgevoerd - [x] Admin /content
- [ ] WCAG 2.1 AA accessibility tests - [x] onderwater heet de is bij Admin de content, theme en plugin de pluginFileTree overal de zelfde naam. Dit is niet logies en consistent. Als de zelfde twig gebruikt word hou dan de naamgeving algemeen.
- [ ] Volledige git commit maken. - [x] Bij Admin/content "Content bestanden" werkt de > bij het openen van de mappen niet. Wees consistenter en compacter. Hergebuik code en maak het universeler. Deze kan gelijk zijn aan de theme en plugin .
- [ ] Verslag maken voor opdrcht gever - [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree
- [ ] Na convormatie versie verhogen - [x] Bij het editen van de content zie ik Zichtbare plugin's. Hier staan nu ook system plugin's in. Maak hier een pulldown multiselect waar ik alleen de active content plugin's ziet
- [ ] Bij Voltooid versie ophoging aanmaken en deze allemaal unvinken voor volgende ronde. - [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent.
- [x] De auteur_name en auteur_email moeten hier weg
## Te doen ⏳ - [x] Ook wil ik dit zie bij elke aanpassing van een pagina --- created: xx-xx-xx xx:xx:xx edited: xx-xx-xx xx:xx:xx ---
- [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin
- [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt.
- [x] GET http://development.codepress.noorlander.info/content/_images/D_D.jpg → 403 Forbidden — opgelost: /admin/media-list content-scope gebruikt nu /-media/ prefix i.p.v. /content/
## Nice to have
- [ ] 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/
@@ -49,8 +53,78 @@
- [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 ![alt](url), 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) ✅
- [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] Installatie instructies toegevoegd aan README (Apache2/Nginx/PHP/composer)
- [x] Verwijderde vendor packages: php-mqtt/client, mustache/mustache (uit composer + autoloader)
- [x] Verslag gemaakt (docs/release-notes/v2.6.1.md)
- [x] Versie verhoogd naar 2.6.1
## v2.6.0 (2026-08-15) ✅ ## v2.6.0 (2026-08-15) ✅
- [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)
- [x] Verslag gemaakt (docs/release-notes/v2.6.0.md) - [x] Verslag gemaakt (docs/release-notes/v2.6.0.md)
- [x] Versie verhoogd naar 2.6.0 - [x] Versie verhoogd naar 2.6.0
+73 -2
View File
@@ -16,9 +16,9 @@ class AdminAuth
*/ */
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'],
]; ];
/** /**
@@ -187,12 +187,71 @@ class AdminAuth
/** /**
* Get the role of the current user. * Get the role of the current user.
* Returns the override role if set (for testing), otherwise the real role.
*/ */
public function getCurrentRole(): string public function getCurrentRole(): string
{ {
if (isset($_SESSION['admin_role_override'])) {
return $_SESSION['admin_role_override'];
}
return $_SESSION['admin_role'] ?? 'admin'; return $_SESSION['admin_role'] ?? 'admin';
} }
/**
* Get the real role of the current user (ignoring any override).
*/
public function getRealRole(): string
{
return $_SESSION['admin_role'] ?? 'admin';
}
/**
* Check if the current user has an active role override.
*/
public function hasRoleOverride(): bool
{
return isset($_SESSION['admin_role_override']);
}
/**
* Temporarily switch to another role for testing purposes.
* Only admin users can do this; the override cannot grant more than admin.
*/
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 the role override back to the real admin role.
*/
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.'];
}
/** /**
* Check if the current user has permission to access a route. * Check if the current user has permission to access a route.
*/ */
@@ -308,9 +367,13 @@ class AdminAuth
/** /**
* Change the role of an existing user. * Change the role of an existing user.
* A user can never change their own role (security guard).
*/ */
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.'];
} }
@@ -432,6 +495,14 @@ class AdminAuth
file_put_contents($this->lockFile, json_encode($attempts)); file_put_contents($this->lockFile, json_encode($attempts));
} }
/**
* Clear all failed login attempts for a user (public, used by CLI reset).
*/
public function clearLockout(string $username): void
{
$this->clearFailedAttempts($username);
}
private function getFailedAttempts(): array private function getFailedAttempts(): array
{ {
if (!file_exists($this->lockFile)) { if (!file_exists($this->lockFile)) {
+9
View File
@@ -0,0 +1,9 @@
<div class="error-page text-center py-5">
<div class="error-code display-1 fw-bold text-primary mb-3">404</div>
<h1 class="h2 mb-3">Pagina niet gevonden</h1>
<p class="text-muted mb-4">De pagina die u zoekt bestaat niet of is verplaatst.</p>
<a href="/" class="btn btn-primary">
<i class="bi bi-house me-1" aria-hidden="true"></i>
Terug naar de hoofdpagina
</a>
</div>
@@ -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: ![alt](url){: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 (![alt](url)) om de grootte in te stellen.');
editor.focus();
break;
}
// Match ![alt](url) optionally followed by {:...}
var imgMatch = selImg.match(/^!\[([^\]]*)\]\(([^)]+)\)(\s*\{:([^}]*)\})?/);
if (!imgMatch) {
alert('Selectie is geen afbeelding. Selecteer een afbeelding in markdown formaat: ![alt](url)');
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 = '![' + alt + '](' + url + ')';
if (attrs.length > 0) {
replacement += '{:' + attrs.join(' ') + '}';
}
editor.replaceSelection(replacement);
editor.focus();
break; break;
} }
} }
+69 -10
View File
@@ -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>
@@ -0,0 +1,43 @@
{# Shared editor layout + file-tree styles for the content, plugin and theme editors.
Included via {% include 'pages/_editor-styles.twig' %} inside the extra_css block. #}
<style>
.editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
.editor-main { flex: 1 1 auto; min-width: 0; }
.editor-tree { list-style: none; padding-left: 0; margin: 0; }
.editor-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.editor-tree li { padding: 0; position: relative; }
.editor-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.editor-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.editor-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); font-weight: 600; }
.editor-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.editor-tree .tree-chevron { flex: 0 0 auto; }
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.editor-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.editor-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.editor-tree .tree-delete-btn:hover { color: #b02a37; }
.editor-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.editor-tree .tree-move-btn:hover { color: #0d6efd; }
.editor-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
/* Directory action buttons (new file/dir, rename, delete) — content editor only */
.tree-dir-row { position: relative; }
.tree-dir-row .tree-toggle { padding-right: 7rem; }
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
.tree-dir-actions .btn-link { color: #6c757d; }
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
@media (max-width: 768px) {
.editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
/* Plugin checkbox group (content editor) */
.plugin-checkbox-group { display: flex; flex-direction: column; gap: .15rem; }
.plugin-checkbox-group .form-check { margin-bottom: 0; }
.plugin-checkbox-group .form-check-label { font-size: .875rem; }
</style>
@@ -0,0 +1,191 @@
{# Shared file-tree partial used by the content, plugin and theme editors.
Renders a nested, collapsible file tree from a list of nodes produced by
scanEditorFiles()/scanContentFiles()/scanPluginFiles()/scanThemeFiles().
Required variables (passed via include with ... context):
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
- relFile: currently selected relative file path (for active highlight + auto-expand)
- editableExts: array of editable extensions (shown as .EXT badge)
- csrf_token: CSRF token for delete forms
- treeIdPrefix: prefix for collapse element ids (e.g. 'content-tree', 'plugin-tree', 'theme-tree')
- treeRouteBase: admin route base used to build file links, e.g. '/admin/content'
- treeRouteParams: extra query string for file links, e.g. 'plugin=MyPlugin&' or 'theme=default&' or '' (content)
- treeDeleteRoute: admin route for file delete, e.g. '/admin/content-file-delete'
- treeDeleteName: optional hidden field name for delete form (e.g. 'plugin' or 'theme'); emits no field when empty
- treeDeleteValue: optional hidden field value for treeDeleteName (e.g. the plugin/theme name)
- treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move'
- treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or ''
- treeDirActions: boolean; show per-folder new-file/new-dir/rename/delete buttons (content editor only)
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
Icon maps are chosen automatically from the node extension. #}
{% macro tree(nodes, opts) %}
{% import _self as macros %}
<ul class="editor-tree">
{% for n in nodes %}
{% set isActive = n.path == opts.relFile %}
{% set containsActive = n.is_dir and opts.relFile starts with (n.path ~ '/') %}
{% if n.is_dir %}
{% set icon = 'bi-folder-fill text-warning' %}
{% elseif n.extension == 'md' %}
{% set icon = 'bi-file-text text-primary' %}
{% elseif n.extension == 'php' %}
{% set icon = 'bi-file-code text-success' %}
{% elseif n.extension == 'twig' %}
{% set icon = 'bi-filetype-tfn text-info' %}
{% elseif n.extension == 'json' %}
{% set icon = 'bi-filetype-json text-secondary' %}
{% elseif n.extension in ['css','scss'] %}
{% set icon = 'bi-filetype-css text-info' %}
{% elseif n.extension == 'js' %}
{% set icon = 'bi-filetype-js text-warning' %}
{% elseif n.extension == 'html' %}
{% set icon = 'bi-file-earmark text-info' %}
{% elseif n.extension in ['jpg','jpeg','png','gif','webp','svg'] %}
{% set icon = 'bi-file-image text-info' %}
{% elseif n.extension == 'pdf' %}
{% set icon = 'bi-file-pdf text-danger' %}
{% else %}
{% set icon = 'bi-file text-muted' %}
{% endif %}
<li>
{% if n.is_dir %}
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
<div class="tree-dir-row">
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
{% if opts.treeDirActions %}
<span class="tree-dir-actions">
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
<i class="bi bi-file-earmark-plus"></i>
</button>
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
<i class="bi bi-folder-plus"></i>
</button>
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
<input type="hidden" name="dir" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
</span>
{% endif %}
</div>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, opts) }}
{% endif %}
</div>
{% else %}
<a href="{{ opts.treeRouteBase }}?{{ opts.treeRouteParams }}file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in opts.editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ opts.treeDeleteRoute }}" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
{% if opts.treeDeleteName %}
<input type="hidden" name="{{ opts.treeDeleteName }}" value="{{ opts.treeDeleteValue }}">
{% endif %}
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{# Render the tree inside the standard sidebar card. The container keeps the
generic id "editorFileTree" so shared CSS/JS selectors work everywhere. #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ treeHeader|default('Bestanden') }}</span>
</div>
<div class="card-body p-2" id="editorFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, {
'relFile': relFile,
'editableExts': editableExts,
'csrfToken': csrf_token,
'treeIdPrefix': treeIdPrefix,
'treeRouteBase': treeRouteBase,
'treeRouteParams': treeRouteParams,
'treeDeleteRoute': treeDeleteRoute,
'treeDeleteName': treeDeleteName|default(''),
'treeDeleteValue': treeDeleteValue|default(''),
'treeMoveRoute': treeMoveRoute,
'treeMoveParams': treeMoveParams,
'treeDirActions': treeDirActions|default(false),
'treeHideActionsFor': treeHideActionsFor|default([]),
}) }}
{% endif %}
</div>
</div>
</div>
<script>
(function () {
var tree = document.getElementById('editorFileTree');
if (!tree) return;
function findToggle(collapseEl) {
var id = collapseEl.id;
if (!id) return null;
return tree.querySelector('[data-bs-target="#' + id + '"]');
}
function expand(btn) {
var icon = btn.querySelector('.tree-chevron');
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
}
function collapse(btn) {
var icon = btn.querySelector('.tree-chevron');
if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); }
}
tree.addEventListener('shown.bs.collapse', function (e) {
var btn = findToggle(e.target);
if (btn) expand(btn);
});
tree.addEventListener('hidden.bs.collapse', function (e) {
var btn = findToggle(e.target);
if (btn) collapse(btn);
});
// Set initial state for folders that are open on page load
Array.prototype.forEach.call(tree.querySelectorAll('.tree-collapse.show'), function (c) {
var btn = findToggle(c);
if (btn) expand(btn);
});
})();
</script>
@@ -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): ![alt](url)
- 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, '&quot;') + '">';
}
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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,17 +29,31 @@
</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">
@@ -51,11 +65,11 @@
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }} <i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button> </button>
{% if isEditable %} {% if isEditable %}
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}"> <a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }} <i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
</a> </a>
{% endif %} {% endif %}
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a> <a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
</div> </div>
</form> </form>
</div> </div>
@@ -64,7 +78,6 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var backBtn = document.getElementById('back-btn'); var backBtn = document.getElementById('back-btn');
var filenameInput = document.getElementById('filename');
var layoutSelect = document.getElementById('layout'); var layoutSelect = document.getElementById('layout');
if (!backBtn) return; if (!backBtn) return;
@@ -78,10 +91,6 @@ document.addEventListener('DOMContentLoaded', function () {
backBtn.classList.add('btn-outline-danger'); backBtn.classList.add('btn-outline-danger');
} }
if (filenameInput) {
filenameInput.addEventListener('input', markChanged);
}
// Update the layout value in the editor's frontmatter when the select changes // Update the layout value in the editor's frontmatter when the select changes
if (layoutSelect) { if (layoutSelect) {
layoutSelect.addEventListener('change', function () { layoutSelect.addEventListener('change', function () {
@@ -0,0 +1,335 @@
{% extends "layouts/admin.twig" %}
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
{% include 'pages/_editor-styles.twig' %}
<style>
/* Git status badge (content editor only) */
.git-status { font-size: .75rem; }
.git-status .badge { font-weight: 400; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.content_editor|default('Content editor') }}</h2>
<div class="d-flex gap-2 flex-wrap">
{# Backup integratie in zijbalk header (git wordt later een systeem plugin) #}
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
</a>
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm">
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="newFileBtnTop" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir|default('') }}">
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
</button>
</div>
</div>
{# Upload form (collapsed by default) #}
<div class="collapse mb-3" id="contentUploadForm">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="/admin/content-file-upload" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="dir" value="" id="contentUploadDir">
<div class="mb-3">
<label for="contentUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
<input type="file" class="form-control" id="contentUploadFile" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav,.css,.scss,.js,.json,.html,.md">
<small class="form-text text-muted">{{ ta.content_upload_help|default('Bestanden worden geüpload naar content/. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.') }}</small>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
</button>
</form>
</div>
</div>
</div>
<div class="editor-layout">
{# File tree sidebar (shared partial) #}
{% include 'pages/_file-tree.twig' with {
'files': files,
'relFile': relFile,
'editableExts': editableExts,
'csrf_token': csrf_token,
'treeIdPrefix': 'content-tree',
'treeHeader': ta.content_files|default('Content bestanden'),
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
'treeRouteBase': '/admin/content',
'treeRouteParams': '',
'treeDeleteRoute': '/admin/content-file-delete',
'treeDeleteName': '',
'treeDeleteValue': '',
'treeMoveRoute': '/admin/content-file-move',
'treeMoveParams': '',
'treeDirActions': true,
'treeHideActionsFor': [],
} %}
{# Editor main panel #}
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> content</li>
{% set crumbPath = '' %}
{% set parts = relFile|split('/') %}
{% for part in parts %}
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
{% if loop.last %}
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
{% else %}
<li class="breadcrumb-item"><a href="/admin/content?file={{ crumbPath|url_encode }}">{{ part }}</a></li>
{% endif %}
{% endfor %}
</ol>
</nav>
{% endif %}
{% if isEditable %}
<form method="POST" action="/admin/content?file={{ relFile|url_encode }}" id="editor-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card shadow-sm">
<div class="card-body">
{# Metadata row: filename display, layout selector, plugins #}
<div class="row mb-3">
<div class="col-md-4">
<label for="new_filename" class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="new_filename" name="new_filename" value="{{ fileBaseName }}" required>
<span class="input-group-text">.{{ fileExt }}</span>
</div>
</div>
<div class="col-md-4">
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }})</small></label>
<select class="form-select form-select-sm" id="layout" name="layout">
<option value="">{{ ta.theme_default|default('Thema standaard') }}</option>
{% for key, layoutFile in themeLayouts %}
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
{% endfor %}
</select>
</div>
{% if availablePlugins is not empty %}
<div class="col-md-4">
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
<div class="plugin-checkbox-group">
{% for plugin in availablePlugins %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{# Created / edited timestamps (read-only, auto-filled) #}
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted small"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted small"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
</div>
</div>
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
</a>
</div>
</form>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
{% if relFile %}
{{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
{% else %}
{{ ta.content_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{# New file modal #}
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/content">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="new_file">
<input type="hidden" name="in_dir" id="newFileDir" value="">
<div class="modal-header">
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.new_file_title|default('Nieuw bestand aanmaken') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newFilenameInput" class="form-label">{{ ta.content_file_path|default('Bestandspad binnen content') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. nl.pagina of blog/nl.post" required autofocus>
</div>
<div class="form-text">{{ ta.content_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. De extensie wordt hieronder gekozen.') }}</div>
</div>
<div class="mb-3">
<label for="newExtSelect" class="form-label">{{ ta.file_type|default('Bestandstype') }}</label>
<select class="form-select" id="newExtSelect" name="new_ext">
<option value="md">Markdown (.md)</option>
<option value="php">PHP (.php)</option>
<option value="html">HTML (.html)</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
</div>
</form>
</div>
</div>
</div>
{# New directory modal #}
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/content-dir-create-in">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="in_dir" id="newDirInDir" value="">
<div class="modal-header">
<h5 class="modal-title" id="newDirModalLabel"><i class="bi bi-folder-plus"></i> {{ ta.new_folder_title|default('Nieuwe map aanmaken') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="dirnameInput" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
<input type="text" class="form-control" id="dirnameInput" name="dirname" required autofocus>
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Pass the in-dir value from the tree button to the modal hidden fields
document.addEventListener('DOMContentLoaded', function () {
var newFileModal = document.getElementById('newFileModal');
var newDirModal = document.getElementById('newDirModal');
var newFileBtnTop = document.getElementById('newFileBtnTop');
var newDirBtnTop = document.getElementById('newDirBtnTop');
// Track the selected directory for the top toolbar buttons
var selectedDir = '{{ selectedDir|default('') }}';
function updateTopButtons(dir) {
selectedDir = dir || '';
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
}
// Make folder rows selectable: clicking the folder name selects it
var tree = document.getElementById('editorFileTree');
if (tree) {
tree.addEventListener('click', function (e) {
var toggle = e.target.closest('.tree-toggle');
if (!toggle) return;
// Find the in-dir from the adjacent new-file button
var row = toggle.closest('.tree-dir-row');
if (!row) return;
var newFileBtn = row.querySelector('.tree-newfile-btn');
var dir = newFileBtn ? newFileBtn.getAttribute('data-in-dir') : '';
updateTopButtons(dir);
// Visually mark the selected folder
tree.querySelectorAll('.tree-toggle.is-selected').forEach(function (el) { el.classList.remove('is-selected'); });
toggle.classList.add('is-selected');
});
}
if (newFileModal) {
newFileModal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
var hidden = document.getElementById('newFileDir');
if (hidden) hidden.value = inDir || '';
// Show the in-dir prefix as a hint in the placeholder
var input = document.getElementById('newFilenameInput');
if (input) {
input.value = '';
input.placeholder = inDir ? inDir + '/naam' : 'Bijv. nl.pagina of blog/nl.post';
}
});
}
if (newDirModal) {
newDirModal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
var hidden = document.getElementById('newDirInDir');
if (hidden) hidden.value = inDir;
});
}
// Mark the initially selected folder (based on the opened file's directory)
if (selectedDir && tree) {
var initialBtn = tree.querySelector('.tree-newfile-btn[data-in-dir="' + selectedDir + '"]');
if (initialBtn) {
var initialToggle = initialBtn.closest('.tree-dir-row').querySelector('.tree-toggle');
if (initialToggle) initialToggle.classList.add('is-selected');
}
}
// Layout select → update frontmatter (spiegel van content-edit.twig)
var layoutSelect = document.getElementById('layout');
if (layoutSelect) {
layoutSelect.addEventListener('change', function () {
var newLayout = this.value;
var cm = window.codeMirrorEditor;
var editor = document.getElementById('editor-textarea');
var content = cm ? cm.getValue() : (editor ? editor.value : '');
var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
var frontmatter = fmMatch[1];
if (/^layout:\s*.+$/m.test(frontmatter)) {
frontmatter = frontmatter.replace(/^layout:\s*.+$/m, 'layout: ' + newLayout);
} else {
frontmatter = 'layout: ' + newLayout + '\n' + frontmatter;
}
content = content.replace(/^---\n([\s\S]*?)\n---/, '---\n' + frontmatter + '\n---');
} else {
content = '---\nlayout: ' + newLayout + '\n---\n\n' + content;
}
if (cm) { cm.setValue(content); } else if (editor) { editor.value = content; }
});
}
});
</script>
{% endblock %}
{% block extra_js %}
{% endblock %}
@@ -1,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>
+7 -4
View File
@@ -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 %}
+130 -17
View File
@@ -5,33 +5,146 @@
{% 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>
<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,
'csrf_token': csrf_token,
'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 ~ '&',
'treeDeleteRoute': '/admin/plugins-file-delete',
'treeDeleteName': 'plugin',
'treeDeleteValue': pluginName,
'treeMoveRoute': '/admin/plugins-file-move',
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
'treeDirActions': false,
'treeHideActionsFor': [],
} %}
{# Editor main panel #}
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> {{ 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>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
{% if relFile %}
{{ ta.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 %}
</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">
<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>
{% 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 %}
+10 -10
View File
@@ -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,164 @@
{% extends "layouts/admin.twig" %}
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
{% include 'pages/_editor-styles.twig' %}
<style>
.theme-scss-status { font-size: .75rem; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }}</h2>
<div class="d-flex gap-2 flex-wrap">
{% if hasScss %}
<form method="POST" action="/admin/theme-scss" class="d-inline" onsubmit="return confirm('{{ ta.compile_scss_confirm|default('SCSS compileren? Dit overschrijft assets/css_compiled/theme.css.') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="theme" value="{{ themeName }}">
<button type="submit" class="btn btn-outline-success btn-sm" title="{{ ta.compile_scss|default('SCSS compileren') }}">
<i class="bi bi-palette"></i> {{ ta.compile_scss|default('SCSS compileren') }}
{% if scssCompiled %}<span class="badge bg-success ms-1">{{ ta.scss_ok|default('up-to-date') }}</span>{% else %}<span class="badge bg-warning text-dark ms-1">{{ ta.scss_stale|default('verouderd') }}</span>{% endif %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#themeUploadForm">
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
<i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file|default('Nieuw bestand') }}
</button>
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
</div>
</div>
{# Upload form (collapsed by default) #}
<div class="collapse mb-3" id="themeUploadForm">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="/admin/theme-file-upload" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="theme" value="{{ themeName }}">
<input type="hidden" name="dir" value="" id="themeUploadDir">
<div class="mb-3">
<label for="themeUploadFile" class="form-label">{{ ta.select_files|default('Bestanden selecteren') }}</label>
<input type="file" class="form-control" id="themeUploadFile" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav,.css,.scss,.js,.json,.html,.md,.twig,.woff,.woff2,.ttf">
<small class="form-text text-muted">{{ ta.theme_upload_help|default('Bestanden worden geüpload naar assets/ van dit thema. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts.') }}</small>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-cloud-upload"></i> {{ ta.upload_to_folder|default('Uploaden') }}
</button>
</form>
</div>
</div>
</div>
<div class="editor-layout">
{# File tree sidebar (shared partial) #}
{% include 'pages/_file-tree.twig' with {
'files': files,
'relFile': relFile,
'editableExts': editableExts,
'csrf_token': csrf_token,
'treeIdPrefix': 'theme-tree',
'treeHeader': ta.theme_files|default('Thema bestanden'),
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
'treeRouteBase': '/admin/theme-edit',
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
'treeDeleteRoute': '/admin/theme-file-delete',
'treeDeleteName': 'theme',
'treeDeleteValue': themeName,
'treeMoveRoute': '/admin/theme-file-move',
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
'treeDirActions': false,
'treeHideActionsFor': ['theme.json'],
} %}
{# Editor main panel #}
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><i class="bi bi-folder2-open"></i> {{ themeName }}</li>
{% set crumbPath = '' %}
{% set parts = relFile|split('/') %}
{% for part in parts %}
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
{% if loop.last %}
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
{% else %}
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ crumbPath|url_encode }}">{{ part }}</a></li>
{% endif %}
{% endfor %}
</ol>
</nav>
{% endif %}
{% if isEditable %}
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ relFile|url_encode }}" id="editor-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card shadow-sm">
<div class="card-body">
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-file-earmark-slash display-6 d-block mb-2"></i>
{% if relFile %}
{{ ta.theme_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
{% else %}
{{ ta.theme_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{# New file modal #}
<div class="modal fade" id="newFileModal" tabindex="-1" aria-labelledby="newFileModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="new_file">
<div class="modal-header">
<h5 class="modal-title" id="newFileModalLabel"><i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file_title|default('Nieuw bestand aanmaken') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newFilenameInput" class="form-label">{{ ta.theme_file_path|default('Bestandspad binnen thema') }}</label>
<input type="text" class="form-control" id="newFilenameInput" name="new_filename" placeholder="Bijv. partials/header.twig of assets/scss/_variables.scss" required autofocus>
<div class="form-text">{{ ta.theme_file_path_help|default('Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. Toegestaan: twig, json, scss, css, js, html, md, php.') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% endblock %}
@@ -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">
+60 -35
View File
@@ -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 %} &rarr; 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 %}
+78 -171
View File
@@ -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">
<div class="col-md-5"> <form method="GET" action="/admin/users" class="row g-2 mb-3">
<!-- Profile edit card for current user --> <div class="col-md-6">
<div class="card shadow-sm mb-4"> <div class="input-group">
<div class="card-header"> <span class="input-group-text"><i class="bi bi-search"></i></span>
<i class="bi bi-person-circle"></i> {{ ta.my_profile|default('Mijn profiel') }} <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>
<div class="card-body"> <div class="col-md-4">
<form method="POST" action="/admin/users"> <select class="form-select" name="role">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <option value="">{{ ta.all_roles|default('Alle rollen') }}</option>
<input type="hidden" name="action" value="profile"> {% for roleKey, roleLabel in roles %}
<input type="hidden" name="profile_username" value="{{ user.username }}"> <option value="{{ roleKey }}" {{ role_filter == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
<div class="mb-3"> {% endfor %}
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label> </select>
<input type="text" class="form-control" value="{{ 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="{{ 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="{{ 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="{{ user.author_email|default('') }}">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
</form>
</div> </div>
</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>
<!-- New user card --> <div class="table-responsive">
<div class="card shadow-sm"> <table class="table table-hover mb-0">
<div class="card-header"> <thead>
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }} <tr>
</div> <th>{{ ta.username|default('Gebruikersnaam') }}</th>
<div class="card-body"> <th>{{ ta.col_role|default('Rol') }}</th>
<form method="POST" action="/admin/users"> <th>{{ ta.login_email|default('Login e-mail') }}</th>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <th>{{ ta.col_created|default('Aangemaakt') }}</th>
<input type="hidden" name="action" value="add"> <th>{{ ta.col_actions|default('Acties') }}</th>
<div class="mb-3"> </tr>
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label> </thead>
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus> <tbody>
</div> {% for username, data in users %}
<div class="mb-3"> <tr>
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label> <td>
<input type="password" class="form-control" id="new_password" name="new_password" required> <i class="bi bi-person-circle"></i>
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small> <a href="/admin/users-edit?user={{ username }}">{{ username }}</a>
</div> {% if username == user.username %}
<div class="mb-3"> <span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label> {% endif %}
<input type="email" class="form-control" id="new_email" name="new_email"> </td>
</div> <td>
<div class="mb-3"> <span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label> {{ data.role_label|default(data.role) }}
<input type="text" class="form-control" id="new_author_name" name="new_author_name"> </span>
</div> </td>
<div class="mb-3"> <td class="text-muted">{{ data.email|default('-') }}</td>
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label> <td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
<input type="email" class="form-control" id="new_author_email" name="new_author_email"> <td>
</div> <a href="/admin/users-edit?user={{ username }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit_profile|default('Profiel bewerken') }}">
<div class="mb-3"> <i class="bi bi-pencil"></i>
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label> </a>
<select class="form-select" id="new_role" name="new_role"> {% if username != user.username %}
{% for roleKey, roleLabel in roles %} <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?') }}')">
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endfor %} <input type="hidden" name="action" value="delete">
</select> <input type="hidden" name="delete_username" value="{{ username }}">
</div> <button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
<button type="submit" class="btn btn-primary w-100"> <i class="bi bi-trash"></i>
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }} </button>
</button> </form>
</form> {% endif %}
</div> </td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php
/**
* CodePress CMS — Admin wachtwoord reset CLI
*
* 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
*/
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
$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 (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 als niet opgegeven
if ($password === '') {
$password = bin2hex(random_bytes(12));
$generated = true;
} else {
$generated = false;
}
// 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";
+1 -1
View File
@@ -3,7 +3,7 @@
# WCAG 2.1 AA Accessibility Test Suite for CodePress CMS # WCAG 2.1 AA Accessibility Test Suite for CodePress CMS
# Tests for web accessibility compliance # Tests for web 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
+6 -10
View File
@@ -3,7 +3,7 @@
# CodePress CMS Functional Test Suite v1.5.0 # CodePress CMS Functional Test Suite v1.5.0
# Tests core functionality, new features, and regressions # Tests core functionality, new features, and regressions
BASE_URL="http://localhost:8080" BASE_URL="http://development.codepress.noorlander.info"
TEST_DATE=$(date '+%Y-%m-%d %H:%M:%S') TEST_DATE=$(date '+%Y-%m-%d %H:%M:%S')
TOTAL_TESTS=0 TOTAL_TESTS=0
PASSED_TESTS=0 PASSED_TESTS=0
@@ -80,14 +80,11 @@ echo ""
echo "2. CONTENT RENDERING TESTS" echo "2. CONTENT RENDERING TESTS"
echo "--------------------------" echo "--------------------------"
# Test Markdown content # Test Markdown content (guide page uses Markdown)
run_test "Markdown rendering" "curl -s '$BASE_URL/?page=demo/content-only' | grep -c '<h1>'" "1" run_test "Markdown rendering" "curl -s '$BASE_URL/?guide' | grep -c '<h1>'" "1"
# Test HTML content # Test 404 handling for non-existent pages
run_test "HTML content" "curl -s '$BASE_URL/?page=demo/html-demo' | grep -c '<h1>'" "1" run_test "404 content handling" "curl -s '$BASE_URL/?page=nonexistent' | grep -c '404'" "1"
# Test PHP content
run_test "PHP content" "curl -s '$BASE_URL/?page=demo/php-demo' | grep -c 'PHP Version'" "1"
echo "" echo ""
echo "3. NAVIGATION TESTS" echo "3. NAVIGATION TESTS"
@@ -97,7 +94,7 @@ echo "-------------------"
run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2" run_test "Menu generation" "curl -s '$BASE_URL/' | grep -c 'nav-item'" "2"
# Test breadcrumb navigation # Test breadcrumb navigation
run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?page=demo/content-only' | grep -c 'breadcrumb'" "1" run_test "Breadcrumb navigation" "curl -s '$BASE_URL/?guide' | grep -c 'breadcrumb'" "1"
echo "" echo ""
echo "4. TEMPLATE SYSTEM TESTS" echo "4. TEMPLATE SYSTEM TESTS"
@@ -243,7 +240,6 @@ Functional testing performed on CodePress CMS v1.5.0 covering core functionality
### Plugin System ### Plugin System
- **HTMLBlock Plugin**: Custom HTML blocks in sidebar - **HTMLBlock Plugin**: Custom HTML blocks in sidebar
- **MQTTTracker Plugin**: Real-time analytics and tracking
- **Plugin Manager**: Centralized plugin loading system - **Plugin Manager**: Centralized plugin loading system
### Enhanced Documentation ### Enhanced Documentation
+1 -1
View File
@@ -2,7 +2,7 @@
# CodePress CMS Penetration Test Script # CodePress CMS Penetration Test Script
# WARNING: Only run this on systems you have permission to test! # WARNING: Only run this on systems you have permission to test!
TARGET="http://localhost:8080" TARGET="http://development.codepress.noorlander.info"
RESULTS_FILE="pentest_results.txt" RESULTS_FILE="pentest_results.txt"
echo "🔒 CodePress CMS Penetration Test" > $RESULTS_FILE echo "🔒 CodePress CMS Penetration Test" > $RESULTS_FILE
+238 -62
View File
@@ -48,8 +48,10 @@ class CodePressCMS {
// Initialize plugin manager (files already loaded in cms/core/index.php) // Initialize plugin manager (files already loaded in cms/core/index.php)
$enabledPlugins = $this->config['enabled_plugins'] ?? []; $enabledPlugins = $this->config['enabled_plugins'] ?? [];
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins); $siteDefaultLang = $this->config['language']['default'] ?? 'nl';
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins, $siteDefaultLang);
$api = new CMSAPI($this); $api = new CMSAPI($this);
$api->setPluginManager($this->pluginManager);
$this->pluginManager->setAPI($api); $this->pluginManager->setAPI($api);
$this->buildMenu(); $this->buildMenu();
@@ -88,8 +90,9 @@ class CodePressCMS {
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes * Removes any characters that are not alphanumeric, dashes, underscores, or slashes
*/ */
private function sanitizePageParam(string $page): string { private function sanitizePageParam(string $page): string {
// Remove any characters that could be used for XSS // Remove any characters that could be used for XSS; keep the dot so
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page); // content file extensions (md/php/html) survive in the URL.
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
return $sanitized ?: 'invalid-page'; return $sanitized ?: 'invalid-page';
} }
@@ -328,6 +331,7 @@ class CodePressCMS {
$result = []; $result = [];
foreach ($items as $item) { foreach ($items as $item) {
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue; if ($item[0] === '.' || $item[0] === '-') continue;
// Skip assets directory (old name, kept for safety) // Skip assets directory (old name, kept for safety)
@@ -356,12 +360,13 @@ class CodePressCMS {
// Always use filename for navigation (not H1 titles from content) // Always use filename for navigation (not H1 titles from content)
$filename = pathinfo($item, PATHINFO_FILENAME); $filename = pathinfo($item, PATHINFO_FILENAME);
$title = $this->formatDisplayName($filename); $title = $this->formatDisplayName($filename);
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath); // Keep the extension in the URL so files sharing the same name
// but different type each keep their own viewable link.
$result[] = [ $result[] = [
'type' => 'file', 'type' => 'file',
'title' => $title, 'title' => $title,
'path' => $pathWithoutExt, 'path' => $relativePath,
'url' => $this->buildUrl($pathWithoutExt) 'url' => $this->buildUrl($relativePath)
]; ];
} }
} }
@@ -395,7 +400,8 @@ class CodePressCMS {
$items = scandir($dir); $items = scandir($dir);
foreach ($items as $item) { foreach ($items as $item) {
if ($item[0] === '.') continue; // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item; $path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item; $relativePath = $prefix ? $prefix . '/' . $item : $item;
@@ -449,17 +455,41 @@ class CodePressCMS {
if (isset($_GET['guide']) || $pageCheck === 'guide') { if (isset($_GET['guide']) || $pageCheck === 'guide') {
return $this->getGuidePage(); return $this->getGuidePage();
} }
// Determine if the request has a valid language prefix
$availableLangs = array_keys($this->getAvailableLanguages());
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
$requestPath = ltrim($requestPath, '/');
$langFromUrl = '';
if ($requestPath !== '' && in_array(explode('/', $requestPath)[0], $availableLangs, true)) {
$langFromUrl = explode('/', $requestPath)[0];
}
// No language prefix and not the root → 404
if ($langFromUrl === '' && $requestPath !== '' && $requestPath !== 'favicon.ico') {
return $this->getError404();
}
// Check if content directory is empty // Check if content directory is empty — show welcome page for new installations
if ($this->isContentDirEmpty()) { if ($this->isContentDirEmpty()) {
return $this->getGuidePage(); $requestedPage = $_GET['page'] ?? '';
// Only show welcome page on the home URL; everything else is a 404
if (empty($requestedPage) || $requestedPage === $this->getEffectiveDefaultPage()) {
return $this->getWelcomePage();
}
return $this->getError404();
} }
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); $page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
// Limit length // Limit length
$page = substr($page, 0, 255); $page = substr($page, 0, 255);
// Only remove file extension at the end, not all dots // Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page); $explicitExt = null;
if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) {
$explicitExt = strtolower($extMatch[1]);
}
// Strip any content extension from the URL for the base path
$pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $page);
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt; $filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
@@ -477,43 +507,32 @@ class CodePressCMS {
$actualFilePath = null; $actualFilePath = null;
// Check for exact file matches if no directory found // If the URL has an explicit extension, try that exact file first
if (file_exists($filePath . '.md')) { if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
$actualFilePath = $filePath . '.md'; $actualFilePath = $filePath . '.' . $explicitExt;
$result = $this->resolveContentByType($filePath, $explicitExt);
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath); if ($result !== null) {
} elseif (file_exists($filePath . '.php')) { $actualFilePath = $result['path'];
$actualFilePath = $filePath . '.php'; $result = $result['content'];
$result = $this->parsePHP($actualFilePath); }
} elseif (file_exists($filePath . '.html')) { }
$actualFilePath = $filePath . '.html'; // No explicit extension: serve the first matching file (md > php > html)
$result = $this->parseHTML(file_get_contents($actualFilePath)); if (!isset($result)) {
} elseif (file_exists($filePath)) { $resolved = $this->resolveContentByType($filePath);
$actualFilePath = $filePath; if ($resolved !== null) {
$extension = pathinfo($filePath, PATHINFO_EXTENSION); $actualFilePath = $resolved['path'];
if ($extension === 'md') { $result = $resolved['content'];
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif ($extension === 'php') {
$result = $this->parsePHP($actualFilePath);
} elseif ($extension === 'html') {
$result = $this->parseHTML(file_get_contents($actualFilePath));
} }
} }
// If no exact match found, check for language-specific versions // If no exact match found, check for language-specific versions
if (!isset($result)) { if (!isset($result)) {
$result = null; // Reset result before language-specific search
$langPrefix = $this->currentLanguage; $langPrefix = $this->currentLanguage;
$langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt;
if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) { $resolved = $this->resolveContentByType($langBase, $explicitExt);
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md'; if ($resolved !== null) {
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath); $actualFilePath = $resolved['path'];
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) { $result = $resolved['content'];
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
} }
} }
@@ -530,6 +549,40 @@ class CodePressCMS {
return $this->getError404(); return $this->getError404();
} }
/**
* Resolve the first content file matching a base path by type priority.
*
* A request without explicit extension (e.g. "test") serves the first
* existing file among test.md → test.php → test.html. When $preferredExt
* is given (the URL carried an extension), that type is tried first so
* /nl/test.php keeps serving test.php even when test.md also exists.
*
* @param string $basePath Absolute path without extension
* @param string $preferredExt Optional extension to try first (md|php|html)
* @return array|null ['path' => string, 'content' => array] or null
*/
private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array
{
$order = ['md', 'php', 'html'];
if ($preferredExt !== null && in_array($preferredExt, $order, true)) {
$order = array_unique(array_merge([$preferredExt], $order));
}
foreach ($order as $ext) {
$candidate = $basePath . '.' . $ext;
if (file_exists($candidate)) {
if ($ext === 'md') {
$content = $this->parseMarkdown(file_get_contents($candidate), $candidate);
} elseif ($ext === 'php') {
$content = $this->parsePHP($candidate);
} else {
$content = $this->parseHTML(file_get_contents($candidate));
}
return ['path' => $candidate, 'content' => $content];
}
}
return null;
}
/** /**
* Get file information including creation and modification dates * Get file information including creation and modification dates
* *
@@ -813,7 +866,11 @@ class CodePressCMS {
private function autoLinkPageTitles($content, $excludeTitle = '') { private function autoLinkPageTitles($content, $excludeTitle = '') {
$pages = $this->getAllPageTitles(); $pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) { foreach ($pages as $page) {
$pageTitle = $page['title'];
$pagePath = $page['path'];
// Alleen bestanden auto-linken, geen mappen
if ($page['type'] === 'folder') continue;
if (strtolower($pageTitle) === strtolower($excludeTitle)) { if (strtolower($pageTitle) === strtolower($excludeTitle)) {
continue; continue;
} }
@@ -840,9 +897,10 @@ class CodePressCMS {
} }
/** /**
* Get all page titles from content directory * Get all content entries (mappen + bestanden) from the content directory.
* *
* @return array Associative array of page paths to titles * @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...]
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/ */
public function getAllPageTitles() { public function getAllPageTitles() {
$pages = []; $pages = [];
@@ -851,7 +909,13 @@ class CodePressCMS {
} }
/** /**
* Recursively scan for page titles in directory * Recursively scan for content entries in a directory.
*
* Returns a flat list of ['path' => relativePath, 'title' => ..., 'type' => ...].
* Mappen krijgen type 'folder'; bestanden krijgen hun extensie als type.
* Bestanden met dezelfde naam maar ander type blijven apart (de extensie
* zit in het pad), en mappen krijgen hun eigen entry zodat de boom zichtbaar
* blijft.
* *
* @param string $dir Directory to scan * @param string $dir Directory to scan
* @param string $prefix Relative path prefix * @param string $prefix Relative path prefix
@@ -865,25 +929,30 @@ class CodePressCMS {
sort($items); sort($items);
foreach ($items as $item) { foreach ($items as $item) {
if ($item[0] === '.') continue; // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item; $path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item; $relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) { if (is_dir($path)) {
$pages[] = [
'path' => $relativePath,
'title' => $this->formatDisplayName($item),
'type' => 'folder',
];
$this->scanForPageTitles($path, $relativePath, $pages); $this->scanForPageTitles($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) { } elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = $this->extractPageTitle($path); $title = $this->extractPageTitle($path);
if ($title && !empty(trim($title))) { if (!$title || empty(trim($title))) {
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $title;
} else {
// Fallback to clean filename if no title found in content
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION)); $filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
$cleanName = $this->formatDisplayName($filename); $title = $this->formatDisplayName($filename);
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $cleanName;
} }
$pages[] = [
'path' => $relativePath,
'title' => $title,
'type' => pathinfo($item, PATHINFO_EXTENSION),
];
} }
} }
} }
@@ -978,9 +1047,15 @@ class CodePressCMS {
// Make API and metadata available to the included file // Make API and metadata available to the included file
$api = new ContentAPI($this); $api = new ContentAPI($this);
$pageMetadata = $metadata; $pageMetadata = $metadata;
include $filePath; $returnedContent = include $filePath;
$content = ob_get_clean(); $content = ob_get_clean();
// If the PHP file returned a string, use that as content (callback style).
// Otherwise use the buffered echo output.
if (is_string($returnedContent) && $returnedContent !== '') {
$content = $returnedContent;
}
// Remove any remaining metadata from PHP output // Remove any remaining metadata from PHP output
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content); $content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
@@ -1048,9 +1123,60 @@ class CodePressCMS {
$files = scandir($contentDir); $files = scandir($contentDir);
$files = array_diff($files, ['.', '..']); $files = array_diff($files, ['.', '..']);
// Filter out hidden files (e.g. .gitkeep) — only count visible content
$files = array_filter($files, function($f) {
return $f[0] !== '.';
});
return empty($files); return empty($files);
} }
/**
* Get welcome page content for new installations (empty content directory)
*
* Shows a clear "new installation" message with next steps when the
* content directory has no content files yet.
*
* @return array Welcome page data
*/
private function getWelcomePage() {
$adminUrl = $this->buildAdminUrl();
$guideUrl = '/' . $this->currentLanguage . '/guide';
$content = '<div class="welcome-page">';
$content .= '<h1>' . htmlspecialchars($this->t('welcome_title')) . '</h1>';
$content .= '<p class="alert alert-info" role="alert">';
$content .= '<i class="bi bi-info-circle me-2" aria-hidden="true"></i>';
$content .= htmlspecialchars($this->t('welcome_intro'));
$content .= '</p>';
$content .= '<h2>' . htmlspecialchars($this->t('welcome_next_steps')) . '</h2>';
$content .= '<ol class="list-group list-group-numbered mb-4">';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_1')) . '</li>';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_2')) . '</li>';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_3')) . '</li>';
$content .= '</ol>';
$content .= '<div class="d-flex flex-column flex-md-row gap-2 mb-4">';
$content .= '<a href="' . htmlspecialchars($adminUrl) . '" class="btn btn-primary">';
$content .= '<i class="bi bi-gear me-1" aria-hidden="true"></i> ';
$content .= htmlspecialchars($this->t('welcome_admin_link'));
$content .= '</a>';
$content .= '<a href="' . htmlspecialchars($guideUrl) . '" class="btn btn-outline-secondary">';
$content .= '<i class="bi bi-book me-1" aria-hidden="true"></i> ';
$content .= htmlspecialchars($this->t('welcome_guide_link'));
$content .= '</a>';
$content .= '</div>';
$content .= '</div>';
return [
'title' => $this->t('welcome_title'),
'content' => $content,
'layout' => 'full_content',
'metadata' => [],
];
}
/** /**
* Get guide page content based on user language * Get guide page content based on user language
* *
@@ -1157,7 +1283,8 @@ class CodePressCMS {
$allItems = []; $allItems = [];
foreach ($items as $item) { foreach ($items as $item) {
if ($item[0] === '.') continue; // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$itemPath = $dirPath . '/' . $item; $itemPath = $dirPath . '/' . $item;
$relativePath = $pagePath ? $pagePath . '/' . $item : $item; $relativePath = $pagePath ? $pagePath . '/' . $item : $item;
@@ -1171,15 +1298,16 @@ class CodePressCMS {
'type' => 'directory' 'type' => 'directory'
]; ];
} elseif (preg_match('/\.(md|php|html)$/', $item)) { } elseif (preg_match('/\.(md|php|html)$/', $item)) {
// Each file keeps its own link (incl. extension) so every type
// stays viewable at the frontend even when names overlap.
$extractedTitle = $this->extractPageTitle($itemPath); $extractedTitle = $this->extractPageTitle($itemPath);
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME)); $fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' : $icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark'); (pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
$allItems[] = [ $allItems[] = [
'name' => $fileTitle, 'name' => $fileTitle,
'path' => $pathWithoutExt, 'path' => $relativePath,
'url' => $this->buildUrl($pathWithoutExt), 'url' => $this->buildUrl($relativePath),
'icon' => $icon, 'icon' => $icon,
'type' => 'file' 'type' => 'file'
]; ];
@@ -1228,9 +1356,17 @@ class CodePressCMS {
* @return array 404 page data * @return array 404 page data
*/ */
private function getError404() { private function getError404() {
$staticFile = __DIR__ . '/../../../admin/static/404.html';
$body = file_exists($staticFile)
? file_get_contents($staticFile)
: '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>';
return [ return [
'title' => $this->t('page_not_found'), 'title' => $this->t('page_not_found'),
'content' => '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>' 'content' => $body,
'layout' => 'full_content',
'metadata' => [],
'is_404' => true,
]; ];
} }
@@ -1252,6 +1388,11 @@ class CodePressCMS {
$page = $this->getPage(); $page = $this->getPage();
$this->pluginManager->doAction('onPageLoad', $page); $this->pluginManager->doAction('onPageLoad', $page);
$this->pluginManager->doAction('onBeforeRender'); $this->pluginManager->doAction('onBeforeRender');
// Set 404 status for not-found pages (rendered through the theme)
if (!empty($page['is_404'])) {
http_response_code(404);
}
$menu = $this->getMenu(); $menu = $this->getMenu();
@@ -1295,6 +1436,10 @@ class CodePressCMS {
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer', 'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#', 'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => $this->config['author']['git'] ?? '', 'author_git' => $this->config['author']['git'] ?? '',
// Per-page author metadata (from frontmatter, if present)
'page_author_name' => htmlspecialchars($page['metadata']['author_name'] ?? ''),
'page_author_email' => htmlspecialchars($page['metadata']['author_email'] ?? ''),
'page_created' => htmlspecialchars($page['metadata']['created'] ?? ''),
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system', 'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based', 'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']), 'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
@@ -1713,6 +1858,37 @@ class CodePressCMS {
private function processContent(string $content): string private function processContent(string $content): string
{ {
return str_replace('-/assets/', '/-assets/', $content); // Legacy rewrite for the old -/assets/ shortcut
$content = str_replace('-/assets/', '/-assets/', $content);
// Rewrite <img src="..."> and <a href="..."> that point to files in the
// content directory so they are served via the /-media/ endpoint. The
// content/ directory lives outside the webroot, so raw URLs like
// "test.svg", "/content/test.svg" or "sub/test.svg" would otherwise 404.
//
// Skipped (left untouched):
// - absolute URLs (http://, https://, //)
// - already rewritten /-media/ and /-assets/ URLs
// - theme/plugin asset URLs (/themes/..., /plugins/..., /admin/...)
// - data: and mailto: URLs
// - fragment-only URLs (#anchor)
$content = preg_replace_callback(
'~(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*")([^"]+)(")~i',
function ($m) {
$url = $m[2];
// Leave absolute, already-rewritten, asset, data: and anchor URLs alone
if (preg_match('~^(?:[a-z][a-z0-9+.\-]*:|//|/themes/|/plugins/|/admin/|/-media/|/-assets/|data:|mailto:)~i', $url)) {
return $m[0];
}
// Strip a leading /content/ prefix if present, then prefix with /-media/
$clean = preg_replace('~^/content/~', '', $url);
// Strip a leading slash so it becomes a clean /-media/<path>
$clean = ltrim($clean, '/');
return $m[1] . '/-media/' . $clean . $m[3];
},
$content
);
return $content;
} }
} }
+40 -9
View File
@@ -17,9 +17,10 @@ class ContentAPI
} }
/** /**
* Get all pages as a flat array of path => title pairs * Get all content entries (mappen + bestanden) as a list of entry arrays.
* *
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons'] * @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/ */
public function getAllPages(): array public function getAllPages(): array
{ {
@@ -29,18 +30,32 @@ class ContentAPI
/** /**
* Get a single page by path, including title, content, layout, and metadata * Get a single page by path, including title, content, layout, and metadata
* *
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1') * De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
* Zonder extensie wordt het eerste bestaande bestand gekozen (md > php > html);
* met extensie wordt dat specifieke bestand gekozen.
*
* @param string $path Page path, al dan niet met extensie
* @return array|null Page data or null if not found * @return array|null Page data or null if not found
*/ */
public function getPage(string $path): ?array public function getPage(string $path): ?array
{ {
$contentDir = $this->cms->config['content_dir']; $contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path);
// Detecteer expliciete extensie en strip hem voor de basis
$preferredExt = null;
if (preg_match('/\.(md|php|html)$/i', $path, $m)) {
$preferredExt = strtolower($m[1]);
}
$path = preg_replace('/\.(md|php|html)$/i', '', $path);
$filePath = $contentDir . '/' . $path; $filePath = $contentDir . '/' . $path;
$extensions = ['md', 'php', 'html']; // Probeer de gevraagde extensie eerst, dan de standaard volgorde
$order = ['md', 'php', 'html'];
if ($preferredExt !== null) {
$order = array_unique(array_merge([$preferredExt], $order));
}
$actualPath = null; $actualPath = null;
foreach ($extensions as $ext) { foreach ($order as $ext) {
if (file_exists($filePath . '.' . $ext)) { if (file_exists($filePath . '.' . $ext)) {
$actualPath = $filePath . '.' . $ext; $actualPath = $filePath . '.' . $ext;
break; break;
@@ -141,14 +156,13 @@ class ContentAPI
/** /**
* Check if a page exists at the given path * Check if a page exists at the given path
* *
* @param string $path Page path without extension * @param string $path Page path, al dan niet met extensie
* @return bool * @return bool
*/ */
public function pageExists(string $path): bool public function pageExists(string $path): bool
{ {
$contentDir = $this->cms->config['content_dir']; $contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path); $basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
$basePath = $contentDir . '/' . $path;
return file_exists($basePath . '.md') return file_exists($basePath . '.md')
|| file_exists($basePath . '.php') || file_exists($basePath . '.php')
@@ -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'] ?? '',
];
}
} }
+55
View File
@@ -8,11 +8,24 @@ class AdminPluginAPI implements PluginAPIInterface
{ {
private array $config; private array $config;
private string $projectRoot; private string $projectRoot;
private string $adminLanguage;
private ?PluginManager $pluginManager = null;
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');
}
/**
* Inject the admin PluginManager so plugins can resolve their own
* translations through the same fallback chain.
*/
public function setPluginManager(PluginManager $pm): void
{
$this->pluginManager = $pm;
} }
/** /**
@@ -79,4 +92,46 @@ class AdminPluginAPI implements PluginAPIInterface
} }
return ['version' => '0.0.0']; return ['version' => '0.0.0'];
} }
/**
* Get the active admin language code (e.g. 'nl', 'en').
* System plugins should use this to resolve their translations.
*/
public function getAdminLanguage(): string
{
return $this->adminLanguage;
}
/**
* Get the translations for a plugin in the admin context.
*
* Fallback chain (handled by PluginManager):
* requested language -> plugin default_language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the active admin language
* @return array Translations [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');
}
/**
* Translate a single key for a plugin in the admin context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the active admin language
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string
{
$t = $this->getPluginTranslations($pluginName, $lang);
return $t[$key] ?? $key;
}
} }
+64 -1
View File
@@ -3,11 +3,21 @@
class CMSAPI implements PluginAPIInterface class CMSAPI implements PluginAPIInterface
{ {
private CodePressCMS $cms; private CodePressCMS $cms;
private ?PluginManager $pluginManager = null;
public function __construct(CodePressCMS $cms) public function __construct(CodePressCMS $cms)
{ {
$this->cms = $cms; $this->cms = $cms;
} }
/**
* Inject the front-end PluginManager so content plugins can resolve
* their own translations through the same fallback chain.
*/
public function setPluginManager(PluginManager $pm): void
{
$this->pluginManager = $pm;
}
/** /**
* Get current page information * Get current page information
@@ -214,10 +224,63 @@ class CMSAPI implements PluginAPIInterface
} }
/** /**
* Get all pages with their metadata * Get all content entries (mappen + bestanden) as a list of entry arrays.
*
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
*/ */
public function getAllPages(): array public function getAllPages(): array
{ {
return $this->cms->getAllPageTitles(); return $this->cms->getAllPageTitles();
} }
/**
* 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'] ?? '',
];
}
/**
* Get the translations for a plugin in the front-end context.
*
* Content plugins follow the current content language. Fallback chain
* (handled by PluginManager): requested language -> plugin
* default_language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the current content language
* @return array Translations [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');
}
/**
* Translate a single key for a plugin in the front-end context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language; defaults to the current content language
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string
{
$t = $this->getPluginTranslations($pluginName, $lang);
return $t[$key] ?? $key;
}
} }
+23
View File
@@ -7,4 +7,27 @@
interface PluginAPIInterface interface PluginAPIInterface
{ {
public function getConfig(string $key, $default = null); public function getConfig(string $key, $default = null);
/**
* Get the translations for a plugin in the current context.
*
* System plugins resolve against the admin language; content plugins
* against the current content language. The implementation handles the
* fallback to the plugin's default_language.
*
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language (optional)
* @return array Translations [key => value]
*/
public function getPluginTranslations(string $pluginName, ?string $lang = null): array;
/**
* Translate a single key for a plugin in the current context.
*
* @param string $key Translation key
* @param string $pluginName Plugin directory name
* @param string|null $lang Override language (optional)
* @return string Translated string, or $key if not found
*/
public function t(string $key, string $pluginName, ?string $lang = null): string;
} }
+131 -2
View File
@@ -8,13 +8,24 @@ class PluginManager
private array $enabledPlugins = []; private array $enabledPlugins = [];
private array $actions = []; private array $actions = [];
private array $filters = []; private array $filters = [];
private string $siteDefaultLanguage = 'nl';
public function __construct(string $pluginsPath, array $enabledPlugins = []) 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();
} }
/**
* Set the CMS site default language. Used as a fallback when a plugin
* does not declare its own `default_language` in plugin.json.
*/
public function setSiteDefaultLanguage(string $lang): void
{
$this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl';
}
public function setAPI($api): void public function setAPI($api): void
{ {
@@ -124,6 +135,123 @@ class PluginManager
return in_array($pluginName, $this->enabledPlugins, true); return in_array($pluginName, $this->enabledPlugins, true);
} }
/**
* Get the runtime config for a plugin: defaults from plugin.json `settings`
* merged with overrides from the plugin's config.json.
*
* @param string $pluginName Plugin name (directory name)
* @return array Resolved config: [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);
}
/**
* Get the default (fallback) language declared by a plugin.
*
* Resolved from (in order):
* 1. plugin.json `default_language`
* 2. CMS site config `language.default`
* 3. 'nl'
*
* @param string $pluginName Plugin directory name
* @return string Language code (e.g. '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';
}
/**
* Load the translations for a plugin for the requested language.
*
* Fallback chain: requested language -> plugin default language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string $lang Requested language code
* @param string $context Translation context: 'admin' or 'site' (front-end).
* Defaults to 'admin' for system plugins, 'site' for content plugins.
* @return array Translations [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 [];
}
/**
* Collect translations for every loaded plugin for the requested language.
*
* Returns an associative array keyed by plugin name:
* ['Statistics' => [...], 'Logs' => [...], ...]
*
* @param string $lang Requested language code
* @param string $context 'admin' or 'site'
* @return array Plugin translations keyed by plugin name
*/
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;
}
public function isPluginViewable(object $plugin): bool public function isPluginViewable(object $plugin): bool
{ {
if (method_exists($plugin, 'getConfig')) { if (method_exists($plugin, 'getConfig')) {
@@ -236,7 +364,7 @@ class PluginManager
* Find which plugin handles a given admin route. * Find which plugin handles a given admin route.
* *
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details') * @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
* @return array|null ['plugin' => name, 'action' => action] or null * @return array|null ['plugin' => name, 'action' => action, 'permission' => required permission] or null
*/ */
public function resolveAdminRoute(string $route): ?array public function resolveAdminRoute(string $route): ?array
{ {
@@ -248,6 +376,7 @@ class PluginManager
return [ return [
'plugin' => $item['plugin'] ?? '', 'plugin' => $item['plugin'] ?? '',
'action' => $action, 'action' => $action,
'permission' => $item['permission'] ?? 'plugins',
]; ];
} }
} }
Generated
+3 -113
View File
@@ -697,59 +697,6 @@
}, },
"time": "2026-01-13T17:56:03+00:00" "time": "2026-01-13T17:56:03+00:00"
}, },
{
"name": "mustache/mustache",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/mustache.php.git",
"reference": "176b6b21d68516dd5107a63ab71b0050e518b7a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/mustache.php/zipball/176b6b21d68516dd5107a63ab71b0050e518b7a4",
"reference": "176b6b21d68516dd5107a63ab71b0050e518b7a4",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.19.3",
"yoast/phpunit-polyfills": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Mustache\\": "src/"
},
"classmap": [
"src/compat.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Justin Hileman",
"email": "justin@justinhileman.info",
"homepage": "http://justinhileman.com"
}
],
"description": "A Mustache implementation in PHP.",
"homepage": "https://github.com/bobthecow/mustache.php",
"keywords": [
"mustache",
"templating"
],
"support": {
"issues": "https://github.com/bobthecow/mustache.php/issues",
"source": "https://github.com/bobthecow/mustache.php/tree/v3.0.0"
},
"time": "2025-06-28T18:28:20+00:00"
},
{ {
"name": "myclabs/php-enum", "name": "myclabs/php-enum",
"version": "1.8.5", "version": "1.8.5",
@@ -967,63 +914,6 @@
}, },
"time": "2025-10-31T00:45:47+00:00" "time": "2025-10-31T00:45:47+00:00"
}, },
{
"name": "php-mqtt/client",
"version": "v2.3.0",
"source": {
"type": "git",
"url": "https://github.com/php-mqtt/client.git",
"reference": "3d141846753a0adee265680ae073cfb9030f2390"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-mqtt/client/zipball/3d141846753a0adee265680ae073cfb9030f2390",
"reference": "3d141846753a0adee265680ae073cfb9030f2390",
"shasum": ""
},
"require": {
"myclabs/php-enum": "^1.7",
"php": "^8.0",
"psr/log": "^1.1|^2.0|^3.0"
},
"require-dev": {
"phpunit/php-invoker": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-redis": "Required for the RedisRepository"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpMqtt\\Client\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marvin Mall",
"email": "marvin-mall@msn.com",
"role": "developer"
}
],
"description": "An MQTT client written in and for PHP.",
"keywords": [
"client",
"mqtt",
"publish",
"subscribe"
],
"support": {
"issues": "https://github.com/php-mqtt/client/issues",
"source": "https://github.com/php-mqtt/client/tree/v2.3.0"
},
"time": "2025-09-30T17:53:34+00:00"
},
{ {
"name": "psr/event-dispatcher", "name": "psr/event-dispatcher",
"version": "1.0.0", "version": "1.0.0",
@@ -1837,10 +1727,10 @@
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": [],
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": {}, "platform": [],
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }
+114
View File
@@ -0,0 +1,114 @@
# CodePress CMS v2.6.1 — Release Notes
**Release datum:** 2026-08-17
**Codename:** Lyra
**Status:** stable
---
## Nieuwe features
### Welkomstpagina bij lege content-map (nieuwe installatie)
- Wanneer de `content/` map leeg is (nieuwe installatie), toont de CMS nu een duidelijke welkomstpagina
- De pagina bevat een introductietekst, een lijst met volgende stappen en knoppen naar de admin console en handleiding
- Vertaald in NL, EN en DE (`welcome_*` keys in `language/*/site.php`)
- `isContentDirEmpty()` filtert nu verborgen bestanden (zoals `.gitkeep`) zodat een map met alleen een `.gitkeep` als leeg wordt herkend
### 404-afhandeling binnen het actieve theme
- Nieuwe statische 404 pagina in `admin/static/404.html` met een link naar de home pagina
- De 404 inhoud wordt binnen het actieve theme gerenderd (met header, navigatie, footer en Bootstrap styling)
- `getError404()` laadt de inhoud uit `admin/static/404.html` en geeft deze als page content terug (layout `full_content`)
- `render()` zet de juiste `http_response_code(404)` status bij niet-gevonden pagina's
### HTTP 404 status bij onbekende pagina's en missende taalprefix
- `getPage()` geeft een 404 als de URL geen geldige taalprefix bevat (bijv. `/random-page` in plaats van `/nl/random-page`)
- Niet-bestaande pagina's binnen een taal (`/nl/nonexistent`) geven nu ook een 404 in plaats van HTTP 200
- Eerder werd bij een lege content-map altijd de welkomstpagina getoond, ook voor niet-bestaande pagina's — dit is nu beperkt tot de home URL
---
## Verbeteringen
### Test scripts
- Test scripts (`pentest.sh`, `accessibility.sh`, `run-tests.sh`) gebruiken nu de Apache-URL (`http://development.codepress.noorlander.info`) in plaats van `localhost:8080`
- Functionele tests opgeschoond: tests die naar niet-bestaande `demo/` content pagina's verwezen vervangen door werkende tests
### Documentatie
- `README.md` en `README.en.md` bijgewerkt: versie naar 2.6.1, multi-language talen gecorrigeerd (NL/EN/DE), `admin/static/` map toegevoegd aan project structuur
- Guide index bestanden (`guide/nl/index.md`, `guide/en/index.md`) bijgewerkt naar versie 2.6.1
- `guide/*/codepress-developer.md` versie referenties bijgewerkt naar 2.6.1
- `AGENTS.md` samengevoegd naar de root map (`./AGENTS.md`); `./development/AGENTS.md` is verwijderd
- AGENTS.md verduidelijkt welke URL bij welke map hoort en dat tests via Apache draaien (niet via PHP dev server)
---
## Opschoning
### Verwijderde ongebruikte bestanden
- `package.json` — was voor NPM build (`npm run build:css`), runtime gebruikt scssphp (PHP) voor SCSS compilatie
- `src/scss/` map — was voor NPM sass build, overbodig na verwijderen van `package.json`
- `.htaccess` (root) — Apache docroot is `public/`, deze root `.htaccess` werd niet geserveerd
- `themes/demo/` — niet actief in `config.json` (gebruikt `default`), alleen genoemd als voorbeeld
### Opschoning referenties
- `README.md` en `README.en.md` — demo theme referenties verwijderd uit project structuur
- `guide/*/codepress-developer/architectuur.md` — demo verwijdering uit mappenstructuur boom
- `themes/gen-preview.sh` — hardcoded `default demo test` loop vervangen door dynamische `*/` loop
- `.gitignore``node_modules/`, `package-lock.json` en `.sass-cache/` regels verwijderd (NPM niet meer gebruikt)
### Verwijderde vendor packages
- `mustache/mustache` — niet meer gebruikt (Twig is de template engine)
- `php-mqtt/client` — niet meer gebruikt
- Beide packages verwijderd uit `composer.lock`, `vendor/composer/installed.json`, `installed.php`, en de autoloader bestanden
- `MQTTTracker` referentie verwijderd uit functionele test script
### Installatie documentatie
- `README.md` en `README.en.md` bevatten nu een volledige installatie sectie met:
- Vereisten (PHP ≥8.0, composer, extensies)
- Apache 2.4+ vhost voorbeeld met `mod_rewrite`, `mod_headers`, `AllowOverride All`
- Nginx server block voorbeeld met PHP-FPM, clean URLs, asset-serving en security headers
- Mappen rechten en test instructies
---
## Beveiliging
- Pentest suite: 30/30 tests geslaagd (XSS, path traversal, PHP injection, null byte, command injection, template injection, HTTP header injection, information disclosure, security headers, DoS)
- 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
- Mobile viewport en touch targets aanwezig
---
## 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. Maak een backup van de huidige content map
2. Pull de nieuwe code
3. Run `composer install` om dependencies bij te werken
4. Test de website
5. Optioneel: initialiseer git in content/ via Admin → Backup & Restore → Git init
---
## Test resultaten samenvatting
| Test | Resultaat |
|------|----------|
| Pentest | 30/30 geslaagd, 0 vulnerabilities |
| WCAG 2.1 AA Accessibility | 25/25 geslaagd, 100% compliance |
+100
View File
@@ -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 |
+173
View File
@@ -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: `![naam](url)`
- 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(-)
+182
View File
@@ -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** — `![alt](test.svg)` werd gerenderd als `<img src="test.svg">` (relatieve URL → 404) of `![alt](/content/test.svg)` 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 `![alt](url){: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 `![alt](url)` 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.
+160
View File
@@ -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).
+100 -23
View File
@@ -1,32 +1,109 @@
# 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/`
- Hidden directories (`.bak`, `.git`) and dotfiles are skipped
- Click a file to open it in the CodeMirror editor
- Folders containing the active file are auto-expanded
- Per folder there are action buttons: new file, new folder, rename, delete
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML) ### Editable file types
- **Toolbar** for quickly inserting Markdown `.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
## Layout selection All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
On the content-edit page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key. #### PHP 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
## Plugins on pages In PHP files, the `ContentAPI` is available via `$api`:
```php
<?php
$menu = $api->getMenu();
$pages = $api->getAllPages();
$config = $api->getConfig('site_title');
return '<h1>' . htmlspecialchars($config) . '</h1>';
```
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection #### Frontend URLs and file names
- Choose which plugins appear in the sidebar 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:
- **Order is adjustable** with up/down buttons - `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
- The plugin order is stored in the frontmatter `plugins:` key - `/nl/test.php` → opens `test.php` (explicit extension)
- Plugins are **hidden** when the chosen layout has no sidebar (e.g. `full_content`) - `/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 `![alt](url)`
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
![logo](/content/_images/logo.png){: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 `![alt](url)` with local URLs (relative like `image.jpg`, or absolute like `/content/_images/image.jpg`) is automatically rewritten to `/-media/...`. External URLs (`https://...`) are left untouched.
### Create a new file
- Click **New file** (at the top or per folder in the sidebar)
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
- Choose the file type (Markdown/PHP/HTML)
- Subfolders are created automatically
- Frontmatter with `layout`, `created`, `edited` is auto-generated
### Upload a file
- Click **Upload** to upload files to `content/`
- Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
- Path-traversal protection: target dir must stay within `content/`
### Move / delete a file
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
- **Move**: choose a target folder from the dropdown listing all folders in content
- **Delete**: with confirmation
### Folder management
- **New folder**: per folder in the sidebar or at the top
- **Rename folder**: pencil icon per folder
- **Delete folder**: trash icon per folder (only empty folders)
### Layout selection
On the editor page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key.
### Plugins on pages
- Only active **content plugins** (from `plugin.json` with `type: "content"`) appear in the plugin multiselect; system plugins (like Statistics/Logs/Dashboard) are excluded
- Choose which plugins run on the page via the multiselect (use Ctrl/Cmd+click for multiple)
- The plugin selection is stored in the frontmatter `plugins:` key
### Backup integration
At the top of the content editor there is a **Backup** button linking to the backup page (`/admin/content-backup`) for ZIP backup/restore and (if available) git versioning. Git integration in the editor sidebar has been removed; git will become a system plugin later.
### Frontmatter
The editor shows the **Created** and **Edited** timestamps (read-only, automatically updated on every save). The `edited:` value is refreshed on each save.
```markdown
---
layout: left_sidebar
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
# Page title
Content...
```
## Frontmatter ## Frontmatter
@@ -35,9 +112,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
```markdown ```markdown
--- ---
layout: left_sidebar layout: left_sidebar
plugins: created: 2026-08-19 10:30:25
- HTMLBlock edited: 2026-08-20 14:22:01
- Navigation plugins: HTMLBlock, Navigation
--- ---
# Page title # Page title
+52 -12
View File
@@ -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
+65 -6
View File
@@ -3,15 +3,72 @@
## 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
- `assets/css_compiled/` is hidden (runtime artefact, read-only)
- Click a file to open it in the CodeMirror editor
- Folders containing the active file are auto-expanded
### Editable file types
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
### Create a new file
- Click **New file**
- Enter a path within the theme (e.g. `partials/header.twig` or `assets/scss/_variables.scss`)
- Subfolders are created automatically
- Allowed: twig, json, scss, css, js, html, md, php
- A stub is auto-generated (e.g. `{% extends 'base.twig' %}` for `.twig`)
### Upload a file
- Click **Upload** to upload files to the theme's `assets/`
- Allowed: images, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts
- Path-traversal protection: target dir must stay within `assets/`
### Move / delete a file
- In the file tree each file has a move button (arrows icon) and a delete button (trash)
- **Move**: choose a target folder from the dropdown listing all folders in the theme
- **Delete**: with confirmation; `theme.json` cannot be deleted
### Compile SCSS from the editor
- At the top of the editor there is a **Compile SCSS** button (only if `assets/scss/theme.scss` exists)
- Shows the compile status: **up-to-date** (green) or **stale** (yellow)
- Forces compilation via `ThemeManager::compileCss(true)`
### Insert media in the editor
- The media button in the editor toolbar opens the media modal
- In theme context it scans `themes/<name>/assets/` (via `/admin/media-list?theme=<name>`)
- Snippet format depends on file type: markdown → `![alt](url)`, 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 +78,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 +112,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
+1 -1
View File
@@ -11,7 +11,7 @@ The CodePress developer guide contains the following topics:
- **Debugging** — Logging and cache - **Debugging** — Logging and cache
- **Performance** — OPcache and SCSS caching - **Performance** — OPcache and SCSS caching
Important concepts in CodePress 2.5.2: Important concepts in CodePress 2.6.1:
- **Plugin types** — Content plugins (sidebar) and system plugins (admin menu, routes, API) - **Plugin types** — Content plugins (sidebar) and system plugins (admin menu, routes, API)
- **Admin plugin API** — System plugins can register admin menu items and routes - **Admin plugin API** — System plugins can register admin menu items and routes
+14 -4
View File
@@ -4,21 +4,31 @@
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
│ ├── config/ # app.php, admin.json │ ├── config/ # app.php, admin.json
│ ├── src/ # AdminAuth │ ├── src/ # AdminAuth
│ └── theme/default/views/ # Twig templates │ └── theme/default/views/ # Twig templates
├── themes/ # Themes (default, demo, ...) ├── 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
│ └── admin.php # Admin entry point + router │ └── admin.php # Admin entry point + router
├── 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()`:
@@ -143,4 +355,6 @@ 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`.
+135 -49
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# Manual # Manual
Welcome to the CodePress CMS manual (version 2.5.2). CodePress is a file-based CMS without a database — content, configuration and users are stored in files. Welcome to the CodePress CMS manual (version 2.6.1). CodePress is a file-based CMS without a database — content, configuration and users are stored in files.
The manual is divided into four sections: The manual is divided into four sections:
+100 -23
View File
@@ -1,32 +1,109 @@
# 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/`
- Verborgen mappen (`.bak`, `.git`) en dotfiles worden overgeslagen
- Klik op een bestand om het te openen in de CodeMirror editor
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
- Per map staan actie-knoppen: nieuw bestand, nieuwe map, hernoemen, verwijderen
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML) ### Bewerkbare bestandstypen
- **Toolbar** voor snel Markdown invoeren `.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
## Layout selectie 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.
Op de content-edit pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key. #### PHP 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
## Plugins op pagina's In PHP bestanden is de `ContentAPI` beschikbaar via `$api`:
```php
<?php
$menu = $api->getMenu();
$pages = $api->getAllPages();
$config = $api->getConfig('site_title');
return '<h1>' . htmlspecialchars($config) . '</h1>';
```
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie #### Frontend URL's en bestandsnamen
- Kies welke plugins in de sidebar verschijnen 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:
- **Volgorde aanpasbaar** met up/down knoppen - `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
- De plugin volgorde wordt opgeslagen in de frontmatter `plugins:` key - `/nl/test.php` → opent `test.php` (expliciete extensie)
- Plugins worden **verbergen** als de gekozen layout geen sidebar heeft (bijv. `full_content`) - `/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 `![alt](url)`
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
![logo](/content/_images/logo.png){: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 `![alt](url)` met lokale URLs (relatief zoals `image.jpg`, of absoluut zoals `/content/_images/image.jpg`) wordt automatisch herschreven naar `/-media/...`. Externe URLs (`https://...`) worden ongewijzigd gelaten.
### Nieuw bestand aanmaken
- Klik op **Nieuw bestand** (bovenaan of per map in de zijbalk)
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
- Kies het bestandstype (Markdown/PHP/HTML)
- Submappen worden automatisch aangemaakt
- Frontmatter met `layout`, `created`, `edited` wordt automatisch gegenereerd
### Bestand uploaden
- Klik op **Upload** om bestanden naar `content/` te uploaden
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD
- Path-traversal bescherming: doelmap moet binnen `content/` blijven
### Bestand verplaatsen / verwijderen
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in content
- **Verwijderen**: met bevestiging
### Mappen beheer
- **Nieuwe map**: per map in de zijbalk of bovenaan
- **Map hernoemen**: potlood-icoon per map
- **Map verwijderen**: prullenbak-icoon per map (alleen lege mappen)
### Layout selectie
Op de editor-pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key.
### Plugins op pagina's
- Alleen actieve **content plugins** (uit `plugin.json` met `type: "content"`) verschijnen in de plugin multiselect; system plugins (zoals Statistics/Logs/Dashboard) zijn uitgesloten
- Kies via de multiselect welke plugins op de pagina draaien (gebruik Ctrl/Cmd+klik voor meerdere)
- De plugin selectie wordt opgeslagen in de frontmatter `plugins:` key
### Backup integratie
Bovenaan de content-editor staat een **Backup** knop die naar de backup-pagina (`/admin/content-backup`) leidt voor ZIP backup/restore en (indien beschikbaar) git versiebeheer. Git integratie in de editor-zijbalk is verwijderd; git wordt later een systeem plugin.
### Frontmatter
De editor toont de **Aangemaakt** en **Bewerkt** timestamps (read-only, automatisch bijgewerkt bij elke opslag). De `edited:` waarde wordt bij elke save vernieuwd.
```markdown
---
layout: left_sidebar
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
# Pagina titel
Content...
```
## Frontmatter ## Frontmatter
@@ -35,9 +112,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
```markdown ```markdown
--- ---
layout: left_sidebar layout: left_sidebar
plugins: created: 2026-08-19 10:30:25
- HTMLBlock edited: 2026-08-20 14:22:01
- Navigation plugins: HTMLBlock, Navigation
--- ---
# Pagina titel # Pagina titel
+52 -12
View File
@@ -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
+66 -6
View File
@@ -3,15 +3,73 @@
## 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
- `assets/css_compiled/` wordt verborgen (runtime artefact, read-only)
- Klik op een bestand om het te openen in de CodeMirror editor
- Mappen die het actieve bestand bevatten zijn automatisch uitgeklapt
### Bewerkbare bestandstypen
`.twig`, `.json`, `.scss`, `.css`, `.js`, `.html`, `.md`, `.php`
### Nieuw bestand aanmaken
- Klik op **Nieuw bestand**
- Geef een pad op binnen het thema (bijv. `partials/header.twig` of `assets/scss/_variables.scss`)
- Submappen worden automatisch aangemaakt
- Toegestaan: twig, json, scss, css, js, html, md, php
- Een stub wordt automatisch gegenereerd (bijv. `{% extends 'base.twig' %}` voor `.twig`)
### Bestand uploaden
- Klik op **Upload** om bestanden naar `assets/` van het thema te uploaden
- Toegestaan: afbeeldingen, video, audio, PDF, ZIP, CSS, SCSS, JS, JSON, HTML, MD, TWIG, fonts
- Path-traversal bescherming: doelmap moet binnen `assets/` blijven
### Bestand verplaatsen / verwijderen
- In de bestandsboom heeft elk bestand een verplaats-knop (pijlen-icoon) en een verwijder-knop (prullenbak)
- **Verplaatsen**: kies een doelmap uit de dropdown met alle mappen in het thema
- **Verwijderen**: met bevestiging; `theme.json` kan niet verwijderd worden
### SCSS compileren vanuit de editor
- Bovenaan de editor staat een **SCSS compileren** knop (alleen als `assets/scss/theme.scss` bestaat)
- Toont de compile-status: **up-to-date** (groen) of **verouderd** (geel)
- Forceert compilatie via `ThemeManager::compileCss(true)`
### Media invoegen in editor
- De media-knop in de editor-toolbar opent de media-modal
- Media wordt getoond als een collapsible **bestandsboom** (folders inklappen/uitklappen), gelijk aan de content/plugin/theme editors
- In thema-context scant deze `themes/<naam>/assets/` (via `/admin/media-list?theme=<naam>`)
- Snippet-formaat depends op bestandstype: markdown → `![alt](url)`, 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 +79,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 +113,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
+1 -1
View File
@@ -11,7 +11,7 @@ De CodePress developer handleiding bevat de volgende onderwerpen:
- **Debugging** — Logging en cache - **Debugging** — Logging en cache
- **Performance** — OPcache en SCSS caching - **Performance** — OPcache en SCSS caching
Belangrijke concepten in CodePress 2.5.2: Belangrijke concepten in CodePress 2.6.1:
- **Plugin types** — Content plugins (sidebar) en systeem plugins (admin menu, routes, API) - **Plugin types** — Content plugins (sidebar) en systeem plugins (admin menu, routes, API)
- **Admin plugin API** — Systeem plugins kunnen admin menu items en routes registreren - **Admin plugin API** — Systeem plugins kunnen admin menu items en routes registreren
+14 -4
View File
@@ -4,21 +4,31 @@
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
│ ├── config/ # app.php, admin.json │ ├── config/ # app.php, admin.json
│ ├── src/ # AdminAuth │ ├── src/ # AdminAuth
│ └── theme/default/views/ # Twig templates │ └── theme/default/views/ # Twig templates
├── themes/ # Thema's (default, demo, ...) ├── 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
│ └── admin.php # Admin entry point + router │ └── admin.php # Admin entry point + router
├── 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()`:
@@ -143,4 +355,6 @@ 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`.
+136 -49
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
# Handleiding # Handleiding
Welkom bij de CodePress CMS handleiding (versie 2.5.2). CodePress is een file-based CMS zonder database — content, configuratie en gebruikers worden opgeslagen in bestanden. Welkom bij de CodePress CMS handleiding (versie 2.6.1). CodePress is een file-based CMS zonder database — content, configuratie en gebruikers worden opgeslagen in bestanden.
De handleiding is verdeeld in vier secties: De handleiding is verdeeld in vier secties:
+76
View File
@@ -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 aus der Seitenleiste zum Bearbeiten.',
'content_file_path' => 'Dateipfad innerhalb von content',
'content_file_path_help' => 'Nur Buchstaben, Zahlen, Punkte, Unterstriche, Bindestriche und Schrägstriche. Unterordner werden automatisch erstellt. Die Erweiterung wird unten ausgewählt.',
'content_upload_help' => 'Dateien werden in content/ hochgeladen. Erlaubt: Bilder, Video, Audio, PDF, ZIP, Office-Dokumente, CSS, SCSS, JS, JSON, HTML, MD.',
'file_type' => 'Dateityp',
'list_view' => 'Listenansicht',
'tree_view' => 'Baumansicht',
'rename_folder' => 'Ordner umbenennen',
'rename_prefix' => 'Umbenennen',
'new_name' => 'Neuer Name',
'git_init' => 'Git init',
'git_init_confirm' => 'Git-Repository in content/ initialisieren?',
'git_commit' => 'Commit',
'git_commit_confirm' => 'Änderungen an git committen?',
'git_dirty' => 'nicht committete Änderungen',
'git_clean' => 'saubere Arbeitskopie',
'git_last_commit' => 'letzter Commit:',
'col_role' => 'Rolle', 'col_role' => 'Rolle',
'col_created' => 'Erstellt', 'col_created' => 'Erstellt',
'no_files_found' => 'Keine Dateien gefunden.', 'no_files_found' => 'Keine Dateien gefunden.',
@@ -105,6 +128,8 @@ return [
'visible_plugins' => 'Sichtbare Plugins', 'visible_plugins' => 'Sichtbare Plugins',
'new_page' => 'Neue Seite', 'new_page' => 'Neue Seite',
'file_type' => 'Dateityp', 'file_type' => 'Dateityp',
'created' => 'Erstellt',
'edited' => 'Bearbeitet',
// Content dir form // Content dir form
'rename_folder' => 'Ordner umbenennen', 'rename_folder' => 'Ordner umbenennen',
@@ -168,6 +193,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 +237,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 +294,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 +314,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.',
]; ];
+10
View File
@@ -35,4 +35,14 @@ return [
'plugin_development' => 'Plugin Entwicklung', 'plugin_development' => 'Plugin Entwicklung',
'template_system' => 'Template System', 'template_system' => 'Template System',
'go_to' => 'Gehe zu', 'go_to' => 'Gehe zu',
'welcome_title' => 'Willkommen bei CodePress CMS',
'welcome_new_install' => 'Neue Installation',
'welcome_intro' => 'Sie sehen diese Seite, weil das Inhaltsverzeichnis noch leer ist. Dies ist eine neue Installation von CodePress CMS.',
'welcome_next_steps' => 'Nächste Schritte',
'welcome_step_1' => 'Melden Sie sich über die Admin-Konsole an, um Inhalte zu verwalten',
'welcome_step_2' => 'Fügen Sie Markdown-, PHP- oder HTML-Dateien zum Inhaltsverzeichnis hinzu',
'welcome_step_3' => 'Lesen Sie das Handbuch für die vollständige Dokumentation',
'welcome_admin_link' => 'Zur Admin-Konsole',
'welcome_guide_link' => 'Handbuch ansehen',
'welcome_content_empty' => 'Das Inhaltsverzeichnis ist leer',
]; ];
+98
View File
@@ -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 from the sidebar to edit.',
'content_file_path' => 'File path within content',
'content_file_path_help' => 'Only letters, numbers, dots, underscores, dashes and slashes. Subfolders are created automatically. The extension is chosen below.',
'content_upload_help' => 'Files are uploaded to content/. Allowed: images, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
'file_type' => 'File type',
'list_view' => 'List view',
'tree_view' => 'Tree view',
'rename_folder' => 'Rename folder',
'rename_prefix' => 'Rename',
'new_name' => 'New name',
'git_init' => 'Git init',
'git_init_confirm' => 'Initialize a git repository in content/?',
'git_commit' => 'Commit',
'git_commit_confirm' => 'Commit changes to git?',
'git_dirty' => 'uncommitted changes',
'git_clean' => 'clean working tree',
'git_last_commit' => 'last commit:',
'col_role' => 'Role', 'col_role' => 'Role',
'col_created' => 'Created', 'col_created' => 'Created',
'no_files_found' => 'No files found.', 'no_files_found' => 'No files found.',
@@ -168,6 +191,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 +235,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 +292,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 +312,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.',
]; ];
+10
View File
@@ -35,4 +35,14 @@ return [
'plugin_development' => 'Plugin Development', 'plugin_development' => 'Plugin Development',
'template_system' => 'Template System', 'template_system' => 'Template System',
'go_to' => 'Go to', 'go_to' => 'Go to',
'welcome_title' => 'Welcome to CodePress CMS',
'welcome_new_install' => 'New installation',
'welcome_intro' => 'You are seeing this page because the content directory is still empty. This is a new installation of CodePress CMS.',
'welcome_next_steps' => 'Next steps',
'welcome_step_1' => 'Log in via the admin console to manage content',
'welcome_step_2' => 'Add Markdown, PHP or HTML files to the content directory',
'welcome_step_3' => 'Read the manual for full documentation',
'welcome_admin_link' => 'Go to admin console',
'welcome_guide_link' => 'View manual',
'welcome_content_empty' => 'The content directory is empty',
]; ];
+100
View File
@@ -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 uit de zijbalk om te bewerken.',
'content_file_path' => 'Bestandspad binnen content',
'content_file_path_help' => 'Alleen letters, cijfers, punten, underscores, streepjes en slashes. Submappen worden automatisch aangemaakt. De extensie wordt hieronder gekozen.',
'content_upload_help' => 'Bestanden worden geüpload naar content/. Toegestaan: afbeeldingen, video, audio, PDF, ZIP, office docs, CSS, SCSS, JS, JSON, HTML, MD.',
'file_type' => 'Bestandstype',
'list_view' => 'Lijst weergave',
'tree_view' => 'Boom weergave',
'rename_folder' => 'Map hernoemen',
'rename_prefix' => 'Hernoem',
'new_name' => 'Nieuwe naam',
'git_init' => 'Git init',
'git_init_confirm' => 'Git repository initialiseren in content/?',
'git_commit' => 'Commit',
'git_commit_confirm' => 'Wijzigingen committen naar git?',
'git_dirty' => 'niet-committed wijzigingen',
'git_clean' => 'schone werkmap',
'git_last_commit' => 'laatste commit:',
'col_role' => 'Rol', 'col_role' => 'Rol',
'col_created' => 'Aangemaakt', 'col_created' => 'Aangemaakt',
'no_files_found' => 'Geen bestanden gevonden.', 'no_files_found' => 'Geen bestanden gevonden.',
@@ -168,6 +193,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 +237,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 +294,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 +314,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.',
]; ];
+10
View File
@@ -35,4 +35,14 @@ return [
'plugin_development' => 'Plugin Ontwikkeling', 'plugin_development' => 'Plugin Ontwikkeling',
'template_system' => 'Template Systeem', 'template_system' => 'Template Systeem',
'go_to' => 'Ga naar', 'go_to' => 'Ga naar',
'welcome_title' => 'Welkom bij CodePress CMS',
'welcome_new_install' => 'Nieuwe installatie',
'welcome_intro' => 'U ziet deze pagina omdat de content-map nog leeg is. Dit is een nieuwe installatie van CodePress CMS.',
'welcome_next_steps' => 'Volgende stappen',
'welcome_step_1' => 'Log in via de admin console om content te beheren',
'welcome_step_2' => 'Voeg Markdown-, PHP- of HTML-bestanden toe in de content-map',
'welcome_step_3' => 'Bekijk de handleiding voor uitgebreide documentatie',
'welcome_admin_link' => 'Naar de admin console',
'welcome_guide_link' => 'Handleiding bekijken',
'welcome_content_empty' => 'De content-map is leeg',
]; ];
-25
View File
@@ -1,25 +0,0 @@
{
"name": "codepress",
"version": "1.7.1",
"description": "A lightweight, file-based Content Management System built with PHP and Bootstrap.",
"main": "index.js",
"scripts": {
"build:css": "npx sass --load-path=node_modules src/scss/main.scss public/assets/css/style.css --style=compressed",
"watch:css": "npx sass --load-path=node_modules --watch src/scss/main.scss public/assets/css/style.css",
"build": "npm run build:css",
"clean": "rm -rf node_modules package-lock.json",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.noorlander.info/E.Noorlander/CodePress.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bootstrap": "^5.3.8",
"sass": "^1.94.2"
}
}
+27 -19
View File
@@ -25,8 +25,10 @@ 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',
], ],
]; ];
} }
@@ -39,6 +41,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 +76,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 +97,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 +111,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 +121,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>
+39
View File
@@ -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()`.
View File
+34
View File
@@ -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.',
];
+34
View File
@@ -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.',
];
+3 -1
View File
@@ -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": []
} }
+17 -6
View File
@@ -2,9 +2,9 @@
class GeoIPInfo class GeoIPInfo
{ {
private ?CMSAPI $api = null; private ?PluginAPIInterface $api = null;
public function setAPI(CMSAPI $api): void public function setAPI(PluginAPIInterface $api): void
{ {
$this->api = $api; $this->api = $api;
} }
@@ -23,7 +23,13 @@ class GeoIPInfo
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',
],
]; ];
} }
@@ -46,11 +52,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>';
} }
+38
View File
@@ -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()`.
View File
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Menu
'menu_label' => 'GeoIP Info',
// Page
'page_title' => 'GeoIP Info',
'ip_address' => 'IP address',
'country' => 'Country',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Menu
'menu_label' => 'GeoIP Info',
// Page
'page_title' => 'GeoIP Info',
'ip_address' => 'IP adres',
'country' => 'Land',
];
+5 -1
View File
@@ -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": []
} }
+14 -8
View File
@@ -20,16 +20,22 @@ class HTMLBlock
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 +44,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 +55,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) {
+30 -91
View File
@@ -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`.
View File
+15
View File
@@ -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',
];
+15
View File
@@ -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',
];
+3 -1
View File
@@ -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": []
} }
+59 -9
View File
@@ -20,19 +20,63 @@ class Logs
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,
], ],
]; ];
} }
/**
* Get this plugin's runtime config (defaults + overrides).
*/
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);
}
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 = dirname(__DIR__, 2) . '/admin/storage/logs';
$logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log'; $logFile = $tab === 'requests' ? $logDir . '/requests.log' : $logDir . '/admin.log';
@@ -40,7 +84,7 @@ class Logs
$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 +100,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>
+49
View File
@@ -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()`.
View File
+32
View File
@@ -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.',
];
+32
View File
@@ -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.',
];
+39 -1
View File
@@ -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
}
]
} }
+47
View File
@@ -0,0 +1,47 @@
# Navigation Plugin
Essentiële content plugin die navigatie toont voor handleidingen en content in de sidebar.
## Plugin type
**Content** plugin. De plugin-output volgt de geselecteerde **content taal** (via `CMSAPI::getPluginTranslations()` met `site.php`).
## Bestandsstructuur
```
Navigation/
├── Navigation.php # Hoofd plugin class
├── plugin.json # Plugin metadata + instellingen
├── README.md # Dit bestand
├── assets/ # CSS/SCSS voor navigatie
│ ├── css/navigation.css
│ └── scss/navigation.scss
└── language/ # Vertalingen
├── nl/site.php # Nederlandse front-end labels
└── en/site.php # Engelse front-end labels
```
## Functies
- **Content navigatie**: Bouwt een sidebar navigatie uit de content-mapstructuur
- **Handleiding navigatie**: Bouwt een sidebar navigatie uit de guide-mapstructuur
- Detecteert automatisch of de huidige pagina een handleiding of content pagina is
- Toont mappen (met kinderen) en bestanden (.md, .php, .html)
- Strip taal-prefixen uit bestandsnamen (bijv. `nl.pagina.md``Pagina`)
## Instellingen
Deze plugin heeft geen instelbare configuratie (`hasConfig: false`). Wel levert hij een CSS-URL via `getCssUrl()`.
## Taal support
De plugin-output bestaat voornamelijk uit bestands- en mapnamen (die uit de content zelf komen), dus er zijn weinig te vertalen UI-strings. De `language/<lang>/site.php` bestanden bevatten momenteel alleen `plugin_title` voor uniformiteit met de andere plugins en toekomstige uitbreiding.
De plugin gebruikt `CMSAPI::getPluginTranslations('Navigation')` om labels op te halen. Fallback chain:
1. Geselecteerde content taal
2. Plugin `default_language` (`nl`)
3. Lege array (sleutel wordt ongewijzigd getoond)
## Beschermd
Deze plugin is **protected**: hardcoded in `public/admin.php` (`getProtectedPlugins()`) en kan niet worden uitgeschakeld, verwijderd of bewerkt via de admin interface.
View File
+7
View File
@@ -0,0 +1,7 @@
<?php
// Navigation is a content plugin. Its sidebar output is built from file and
// directory names, so there are very few translatable UI strings. This file
// exists for uniformity with the other plugins and to allow future labels.
return [
'plugin_title' => 'Navigation',
];
+7
View File
@@ -0,0 +1,7 @@
<?php
// Navigation is a content plugin. Its sidebar output is built from file and
// directory names, so there are very few translatable UI strings. This file
// exists for uniformity with the other plugins and to allow future labels.
return [
'plugin_title' => 'Navigatie',
];
+3 -1
View File
@@ -5,5 +5,7 @@
"description": "Essentiële navigatie plugin voor handleidingen en content", "description": "Essentiële navigatie plugin voor handleidingen en content",
"type": "content", "type": "content",
"essential": true, "essential": true,
"hasConfig": false "hasConfig": false,
"default_language": "nl",
"settings": []
} }
+76 -79
View File
@@ -1,102 +1,99 @@
# CodePress CMS Plugins # CodePress CMS Plugins
Deze map bevat plugins voor de CodePress CMS. Elke plugin heeft zijn eigen submap met de plugin code. Deze map bevat alle plugins voor de CodePress CMS. Elke plugin heeft zijn eigen submap.
## Plugin Structuur ## Plugin overzicht
Elke plugin map moet het volgende bevatten: | Plugin | Type | Essentieel | Beschrijving |
|--------|------|------------|--------------|
| Dashboard | system | ja | Site informatie, content statistieken en plugin overzicht op het dashboard |
| GeoIPInfo | system | nee | GeoIP-informatie (IP adres en land) in admin |
| HTMLBlock | content | nee | Custom HTML-blok in de sidebar van content-pagina's |
| Logs | system | nee | Admin activiteitlogs en front-end request logs met filter tabs |
| Navigation | content | ja | Navigatie voor handleidingen en content (beschermd) |
| Statistics | system | nee | Bezoekersstatistieken: views, unieke bezoekers, landen, top pagina's |
Elke plugin heeft een eigen `README.md` met specifieke documentatie.
## Plugin structuur (uniform)
Elke plugin-map heeft de volgende structuur:
``` ```
PluginName/ PluginName/
├── PluginName.php # Hoofd plugin bestand ├── PluginName.php # Hoofd plugin class (naam = mapnaam)
├── README.md # Plugin documentatie (optioneel) ├── plugin.json # Plugin metadata + instellingen-schema
├── config.json # Plugin configuratie (optioneel) ├── README.md # Plugin documentatie
── assets/ # CSS, JS, images (optioneel) ── config.json # Optionele runtime configuratie (overschrijft defaults)
├── css/ ├── assets/ # Optionele CSS/JS/SCSS (.gitkeep als leeg)
├── js/ ├── css/
└── images/ └── scss/
└── language/ # Vertalingen
├── nl/
│ ├── admin.php # Admin labels (systeem plugins)
│ └── site.php # Front-end labels (content plugins)
└── en/
├── admin.php
└── site.php
``` ```
## Beschikbare Plugins ## plugin.json velden
### HTMLBlock | Veld | Waarde | Beschrijving |
Toont een custom HTML blok in de sidebar met pagina-informatie en navigatie. |------|--------|--------------|
| `name` | string | Weergavenaam in admin |
| `version` | string | Versienummer |
| `author` | string | Auteur |
| `description` | string | Korte beschrijving |
| `type` | `"system"` of `"content"` | Systeem (blauwe badge) of content (groene badge) |
| `essential` | boolean | Essentiële plugins kunnen niet worden bewerkt/verwijderd |
| `hasConfig` | boolean | Toont een Config-knop in admin |
| `default_language` | string | Fallback taal voor plugin-vertalingen (bijv. `nl`) |
| `settings` | array | Instellingen-schema (zie hieronder) |
**Locatie:** `HTMLBlock/HTMLBlock.php` ### Instellingen-schema
**Functies:** Elke instelling in `settings` ondersteunt:
- Toont huidige pagina informatie
- Dynamische navigatie
- Bestandsinformatie
- Interactive controls
## Plugin Development | 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`) |
| `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 optie-labels voor `select`/`multi-select` |
| `options` | Opties voor `select`/`multi-select` (`{waarde: label}`) |
### Basis Plugin Class ## Taal support
```php - **Systeem plugins** volgen de geselecteerde **admin taal** (uit `config.admin_language`).
<?php - **Content plugins** volgen de geselecteerde **content taal** (uit de URL of `config.language.default`).
class MyPlugin Fallback chain voor plugin-vertalingen:
{ 1. Geselecteerde taal (`language/<lang>/admin.php` of `site.php`)
private ?CMSAPI $api = null; 2. Plugin `default_language` (`plugin.json`)
3. CMS `language.default`
public function setAPI(CMSAPI $api): void 4. Lege array (sleutel wordt ongewijzigd getoond)
{
$this->api = $api;
}
public function getSidebarContent(): string
{
return '<div>Mijn plugin content</div>';
}
}
```
### Beschikbare API Methodes Plugins halen hun vertalingen op via de API:
- Systeem plugins: `$this->api->getPluginTranslations('PluginName')` (AdminPluginAPI)
- Content plugins: `$this->api->getPluginTranslations('PluginName')` (CMSAPI)
- `getCurrentPage()` - Huidige pagina data ## Uitgebreide documentatie
- `getCurrentPageTitle()` - Huidige pagina titel
- `getMenu()` - Menu structuur
- `getConfig($key)` - Configuratie waardes
- `translate($key)` - Vertalingen
- `getCurrentLanguage()` - Huidige taal
- `isHomepage()` - Check of homepage
- `getCurrentPageFileInfo()` - Bestandsinformatie
- `createUrl($page, $lang)` - URL generatie
### Plugin Hooks Zie de handleiding voor uitgebreide plugin development documentatie:
Plugins kunnen de volgende methodes implementeren: - `guide/nl/codepress-developer/plugin-development.md`
- `guide/en/codepress-developer/plugin-development.md`
- `getSidebarContent()` - Content voor sidebar
- `setAPI(CMSAPI $api)` - API injectie
## Configuratie
Plugins kunnen een `config.json` bestand hebben:
```json
{
"enabled": true,
"settings": {
"option1": "value1",
"option2": "value2"
}
}
```
## Installatie ## Installatie
1. Maak een nieuwe map in `plugins/` 1. Maak een nieuwe map in `plugins/` (mapnaam = plugin class naam)
2. Plaats de plugin class in `PluginName/PluginName.php` 2. Voeg de hoofd plugin class toe als `<Naam>.php`
3. Optioneel: voeg README.md en config.json toe 3. Maak een `plugin.json` met metadata en (optioneel) instellingen-schema
4. De plugin wordt automatisch geladen door de CMS 4. Voeg een `README.md` toe met plugin documentatie
5. Voeg `language/<lang>/admin.php` of `site.php` toe voor vertalingen
## Best Practices 6. Schakel de plugin in via `enabled_plugins` in `config.json` (of via de admin Plugins-pagina)
- Gebruik `htmlspecialchars()` voor output
- Implementeer `setAPI()` voor CMS toegang
- Volg PSR-12 coding standards
- Gebruik namespace indien nodig
- Documenteer je plugin met README.md

Some files were not shown because too many files have changed in this diff Show More