Compare commits
2
Commits
v2.6.3
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ea2eee47 | ||
|
|
74612aefbb |
+2
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
A lightweight, file-based content management system built with PHP (≥8.0).
|
A lightweight, file-based content management system built with PHP (≥8.0).
|
||||||
|
|
||||||
**Version:** 2.6.3 | **License:** AGPL v3 / Commercial
|
**Version:** 2.6.5 | **License:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -55,10 +55,9 @@ composer install
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.json.example config.json
|
cp config.json.example config.json
|
||||||
cp admin/config/admin.json.example admin/config/admin.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `config.json` with your site title, language and plugins. Change the admin password in `admin/config/admin.json` (default `admin`/`admin`).
|
Edit `config.json` with your site title, language and plugins. On the first visit to `/admin`, `admin/config/admin.json` is created automatically with a random password that is shown on the login screen. Save this password safely and change it immediately after login.
|
||||||
|
|
||||||
### Step 3a — Apache 2.4+
|
### Step 3a — Apache 2.4+
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
|
||||||
|
|
||||||
**Versie:** 2.6.3 | **Licentie:** AGPL v3 / Commercial
|
**Versie:** 2.6.5 | **Licentie:** AGPL v3 / Commercial
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -55,10 +55,9 @@ composer install
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.json.example config.json
|
cp config.json.example config.json
|
||||||
cp admin/config/admin.json.example admin/config/admin.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pas `config.json` aan met je site titel, taal en plugins. Wijzig het admin wachtwoord in `admin/config/admin.json` (standaard `admin`/`admin`).
|
Pas `config.json` aan met je site titel, taal en plugins. Bij de eerste keer dat je `/admin` opent wordt `admin/config/admin.json` automatisch aangemaakt met een willekeurig wachtwoord dat op het inlogscherm wordt getoond. Sla dit wachtwoord veilig op en wijzig het direct na login.
|
||||||
|
|
||||||
### Stap 3a — Apache 2.4+
|
### Stap 3a — Apache 2.4+
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## bug
|
|
||||||
- [x] Admin /content
|
## Voltooid ✅
|
||||||
- [x] onderwater heet de is bij Admin de content, theme en plugin de pluginFileTree overal de zelfde naam. Dit is niet logies en consistent. Als de zelfde twig gebruikt word hou dan de naamgeving algemeen.
|
|
||||||
- [x] Bij Admin/content "Content bestanden" werkt de > bij het openen van de mappen niet. Wees consistenter en compacter. Hergebuik code en maak het universeler. Deze kan gelijk zijn aan de theme en plugin .
|
- [x] diep link werken niet goed. Pagina titels worden wel herkend maar de gegenereerde link werken niet. — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404). Skip-regex uitgebreid met dynamische taal-prefixen via getAvailableLanguages().
|
||||||
- [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree
|
- [x] De tree van de admin/content admin/theme en admin/plugin
|
||||||
- [x] Bij het editen van de content zie ik Zichtbare plugin's. Hier staan nu ook system plugin's in. Maak hier een pulldown multiselect waar ik alleen de active content plugin's ziet
|
- [x] Mappen en bestanden moeten kunnen worden versleept kunnen worden om te verplaatsen. — Drag-and-drop via tree-interactions.js + uniform AJAX move endpoint (/admin/tree-move) voor alle drie de scopes.
|
||||||
- [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent.
|
- [x] Ik moet in de tree ook een bestand/map kunnen selecteren
|
||||||
- [x] De auteur_name en auteur_email moeten hier weg
|
- [x] om te kunnen editen — klik op bestand opent editor, klik op map opent map-detail paneel
|
||||||
- [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] verwijderen — prullenbak-knop per bestand in tree + verwijder-knop per map in map-detail paneel
|
||||||
- [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin
|
- [x] op deze plaats een nieuwe map/bestand te kunnen maken — per-map actie-knoppen in tree (nieuw bestand, nieuwe map) + knoppen in map-detail paneel
|
||||||
- [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt.
|
- [x] verplaatsen — drag-and-drop + potlood-knop per bestand (verplaats-formulier)
|
||||||
- [x] GET http://development.codepress.noorlander.info/content/_images/D_D.jpg → 403 Forbidden — opgelost: /admin/media-list content-scope gebruikt nu /-media/ prefix i.p.v. /content/
|
- [x] Alle tree's moeten op de zelfde manier kunnen werken — _file-tree.twig is uniform: alle drie de trees hebben treeDirActions, treeDirSelectRoute, drag-drop, per-node actie-knoppen, map-detail paneel. Nieuwe routes: theme-dir-create-in/rename-in/delete-in, plugins-dir-create-in/rename-in/delete-in, tree-move.
|
||||||
|
|
||||||
## Nice to have
|
## 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)
|
||||||
@@ -37,6 +37,19 @@
|
|||||||
- [ ] AGENTS.md + config.json.example bijwerken
|
- [ ] AGENTS.md + config.json.example bijwerken
|
||||||
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
|
||||||
|
|
||||||
|
## v2.6.5 (2026-08-27) ✅
|
||||||
|
- [x] Bug: dashboard toonde 0 content (AdminPluginAPI::getContentDir() gaf relatief pad "content" terug zonder normalisatie tegen project-root; Apache CWD=public/ → 0 bestanden)
|
||||||
|
- [x] Dynamische pad-resolutie: PluginAPIInterface uitgebreid met getProjectRoot()/getContentDir()/getPluginsDir()/getVersionInfo(); CMSAPI en AdminPluginAPI implementeren deze nu universeel; Navigation plugin en Logs plugin halen paden via de API i.p.v. hardcoded dirname(__DIR__)
|
||||||
|
- [x] public/index.php media-/assets-serving gebruikt $config['content_dir'] i.p.v. hardcoded /content
|
||||||
|
- [x] WordPress-stijl docblocks toegevoegd voor alle classes, methods, properties en functies (~450 docblocks, @since 2.6.5)
|
||||||
|
- [x] Security: hardcoded plaintext-wachtwoord 'admin' verwijderd uit AdminAuth.php; bij eerste installatie wordt een cryptografisch veilig wachtwoord gegenereerd (random_bytes, 16 tekens) en eenmalig op het inlogscherm getoond
|
||||||
|
- [x] Security: git-geschiedenis schoongemaakt — admin/config/admin.json, admin/config/admin.json.example en admin-console/config/admin.json verwijderd uit alle commits (bevatten kraakbare bcrypt-hashes naar 'admin'); filter-branch over alle branches + 10 tags, reflog expired, gc --prune --aggressive
|
||||||
|
- [x] README.md, README.en.md, AGENTS.md bijgewerkt (admin.json.example referenties verwijderd, inlog-instructies herschreven)
|
||||||
|
|
||||||
|
## v2.6.4 (2026-08-26) ✅
|
||||||
|
- [x] Bug: diep link werken niet goed — processContent() herschreef geldige /<lang>/<page> URLs naar /-media/ (404); skip-regex dynamisch uitgebreid met getAvailableLanguages()
|
||||||
|
- [x] Bug: uniforme tree met drag-and-drop voor admin/content, admin/theme, admin/plugin — tree-interactions.js (drag-drop + per-node actie-knoppen), uniform AJAX move endpoint (/admin/tree-move), map-detail paneel voor alle drie de scopes, theme/plugin dir create/rename/delete routes, handleidingen bijgewerkt
|
||||||
|
|
||||||
## Voltooid ✅
|
## Voltooid ✅
|
||||||
|
|
||||||
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
|
||||||
|
|||||||
+421
-20
@@ -1,18 +1,60 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminAuth - File-based authentication for CodePress Admin
|
* File-based authenticatie en autorisatie voor het CodePress admin paneel.
|
||||||
|
*
|
||||||
|
* Beheert gebruikerssessies, rollen met permissies, CSRF-tokens, brute-force
|
||||||
|
* lockout en gebruikersbeheer. Alle data wordt opgeslagen in JSON-bestanden
|
||||||
|
* (geen database).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
*/
|
*/
|
||||||
class AdminAuth
|
class AdminAuth
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Applicatie-configuratie uit config.json.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Applicatie-configuratie.
|
||||||
|
*/
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-configuratie geladen uit admin.json (gebruikers en security-instellingen).
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array Admin-configuratie met keys 'users' en 'security'.
|
||||||
|
*/
|
||||||
private array $adminConfig;
|
private array $adminConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad naar het JSON-bestand met mislukte inlogpogingen voor brute-force lockout.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Absoluut pad naar login_attempts.json.
|
||||||
|
*/
|
||||||
private string $lockFile;
|
private string $lockFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role definitions with permissions.
|
* Tijdelijk gegenereerd wachtwoord bij eerste installatie.
|
||||||
* Each role maps to a list of allowed route prefixes.
|
*
|
||||||
* 'admin' has wildcard '*' access.
|
* Wordt gevuld wanneer loadAdminConfig() een nieuwe admin.json aanmaakt
|
||||||
|
* met een cryptografisch veilig willekeurig wachtwoord. De login-pagina
|
||||||
|
* toont dit wachtwoord eenmalig via getGeneratedPassword().
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var string Leeg tenzij net aangemaakt bij eerste installatie.
|
||||||
|
*/
|
||||||
|
private string $generatedPassword = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissies per rol.
|
||||||
|
*
|
||||||
|
* Elke rol mapt naar een lijst van toegestane route-prefixen. De rol
|
||||||
|
* 'admin' heeft wildcard '*' toegang tot alle routes.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string, string[]> Map van rol-sleutel naar lijst met toegestane routes.
|
||||||
*/
|
*/
|
||||||
public const ROLE_PERMISSIONS = [
|
public const ROLE_PERMISSIONS = [
|
||||||
'admin' => ['*'],
|
'admin' => ['*'],
|
||||||
@@ -22,7 +64,10 @@ class AdminAuth
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable role labels.
|
* Leesbare rol-labels voor weergave in de admin-interface.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
* @var array<string, string> Map van rol-sleutel naar label.
|
||||||
*/
|
*/
|
||||||
public const ROLE_LABELS = [
|
public const ROLE_LABELS = [
|
||||||
'admin' => 'Admin',
|
'admin' => 'Admin',
|
||||||
@@ -31,6 +76,16 @@ class AdminAuth
|
|||||||
'site-admin' => 'Site Admin',
|
'site-admin' => 'Site Admin',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor: initialiseer authenticatie met de applicatie-configuratie.
|
||||||
|
*
|
||||||
|
* Laadt de admin-configuratie, bepaalt het pad naar het lockout-bestand en
|
||||||
|
* start de sessie met de juiste security-instellingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param array $appConfig Applicatie-configuratie; vereist keys 'log_file' en 'admin_config'.
|
||||||
|
*/
|
||||||
public function __construct(array $appConfig)
|
public function __construct(array $appConfig)
|
||||||
{
|
{
|
||||||
$this->config = $appConfig;
|
$this->config = $appConfig;
|
||||||
@@ -39,6 +94,17 @@ class AdminAuth
|
|||||||
$this->startSession();
|
$this->startSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laadt de admin-configuratie uit admin.json.
|
||||||
|
*
|
||||||
|
* Als het bestand nog niet bestaat wordt het aangemaakt vanuit
|
||||||
|
* admin.json.example, of indien die ook ontbreekt met een standaard admin-
|
||||||
|
* gebruiker. Geeft een lege structuur terug als de JSON ongeldig is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Admin-configuratie met keys 'users' en 'security'.
|
||||||
|
*/
|
||||||
private function loadAdminConfig(): array
|
private function loadAdminConfig(): array
|
||||||
{
|
{
|
||||||
$path = $this->config['admin_config'];
|
$path = $this->config['admin_config'];
|
||||||
@@ -48,11 +114,19 @@ class AdminAuth
|
|||||||
if (file_exists($examplePath)) {
|
if (file_exists($examplePath)) {
|
||||||
@copy($examplePath, $path);
|
@copy($examplePath, $path);
|
||||||
} else {
|
} else {
|
||||||
|
// Genereer een cryptografisch veilig wachtwoord bij eerste
|
||||||
|
// installatie. Het wachtwoord wordt nooit in de broncode
|
||||||
|
// opgeslagen; de hash wordt weggeschreven naar admin.json
|
||||||
|
// en het plaintext wachtwoord éénmalig getoond op het
|
||||||
|
// inlogscherm via getGeneratedPassword().
|
||||||
|
$plainPassword = $this->generateRandomPassword();
|
||||||
|
$this->generatedPassword = $plainPassword;
|
||||||
|
|
||||||
$defaultAdminConfig = [
|
$defaultAdminConfig = [
|
||||||
'users' => [
|
'users' => [
|
||||||
[
|
[
|
||||||
'username' => 'admin',
|
'username' => 'admin',
|
||||||
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
|
'password_hash' => password_hash($plainPassword, PASSWORD_BCRYPT),
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
'created' => date('Y-m-d'),
|
'created' => date('Y-m-d'),
|
||||||
]
|
]
|
||||||
@@ -74,6 +148,55 @@ class AdminAuth
|
|||||||
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genereer een cryptografisch veilig willekeurig wachtwoord.
|
||||||
|
*
|
||||||
|
* Gebruik random_bytes() (CSPRNG) met een mix van alfanumerieke tekens
|
||||||
|
* en een vaste lengte, zodat het wachtwoord voldoende entropie heeft
|
||||||
|
* en veilig te typen is.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Willekeurig wachtwoord van 16 alfanumerieke tekens.
|
||||||
|
*/
|
||||||
|
private function generateRandomPassword(int $length = 16): string
|
||||||
|
{
|
||||||
|
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
$max = strlen($alphabet) - 1;
|
||||||
|
$password = '';
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$password .= $alphabet[random_int(0, $max)];
|
||||||
|
}
|
||||||
|
return $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geef het bij eerste installatie gegenereerde wachtwoord terug.
|
||||||
|
*
|
||||||
|
* De login-pagina gebruikt dit om het wachtwoord eenmalig te tonen
|
||||||
|
* zodra admin.json is aangemaakt. Na de eerste succesvolle login
|
||||||
|
* wordt het veld geleegd. Geeft een lege string terug wanneer er
|
||||||
|
* geen nieuw wachtwoord is gegenereerd in deze request.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Plaintext wachtwoord of lege string.
|
||||||
|
*/
|
||||||
|
public function getGeneratedPassword(): string
|
||||||
|
{
|
||||||
|
return $this->generatedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slaat de huidige admin-configuratie op naar admin.json.
|
||||||
|
*
|
||||||
|
* Schrijft de interne admin-configuratie weg als pretty-printed JSON met
|
||||||
|
* behoud van unicode-tekens.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function saveAdminConfig(): void
|
public function saveAdminConfig(): void
|
||||||
{
|
{
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
@@ -82,6 +205,17 @@ class AdminAuth
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start de sessie met veilige cookie-parameters en handhaaft de session timeout.
|
||||||
|
*
|
||||||
|
* Stelt cookie-flags in (HttpOnly, SameSite=Strict, Secure bij HTTPS), start
|
||||||
|
* de sessie en beëindigt deze bij inactiviteit langer dan de geconfigureerde
|
||||||
|
* timeout. Vernieuwt de activiteit-timestamp bij geauthenticeerde gebruikers.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function startSession(): void
|
private function startSession(): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -113,6 +247,20 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifieert inloggegevens en start een admin-sessie bij succes.
|
||||||
|
*
|
||||||
|
* Controleert eerst of het account niet is vergrendeld door brute-force
|
||||||
|
* protection. Bij een fout wachtwoord wordt een mislukte poging geregistreerd
|
||||||
|
* en gelogd. Bij succes worden mislukte pogingen gewist en de sessie
|
||||||
|
* gevuld met gebruiker, rol en CSRF-token.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $password Wachtwoord (plaintext).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function login(string $username, string $password): array
|
public function login(string $username, string $password): array
|
||||||
{
|
{
|
||||||
// Check brute-force lockout
|
// Check brute-force lockout
|
||||||
@@ -145,6 +293,16 @@ class AdminAuth
|
|||||||
return ['success' => true, 'message' => 'Ingelogd.'];
|
return ['success' => true, 'message' => 'Ingelogd.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logt de huidige gebruiker uit en vernietigt de sessie.
|
||||||
|
*
|
||||||
|
* Maakt de sessie-array leeg, verwijdert het sessie-cookie en vernietigt de
|
||||||
|
* sessie. De uitlogactie wordt gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
$user = $_SESSION['admin_user'] ?? 'unknown';
|
$user = $_SESSION['admin_user'] ?? 'unknown';
|
||||||
@@ -160,11 +318,28 @@ class AdminAuth
|
|||||||
$this->log('info', "Uitgelogd: {$user}");
|
$this->log('info', "Uitgelogd: {$user}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleert of de huidige bezoeker is ingelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien een admin-gebruiker in de sessie staat.
|
||||||
|
*/
|
||||||
public function isAuthenticated(): bool
|
public function isAuthenticated(): bool
|
||||||
{
|
{
|
||||||
return isset($_SESSION['admin_user']);
|
return isset($_SESSION['admin_user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft de gegevens van de ingelogde gebruiker, verrijkt met profielvelden.
|
||||||
|
*
|
||||||
|
* Levert username en rol uit de sessie en vult deze aan met email,
|
||||||
|
* author_name en author_email uit admin.json indien aanwezig.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array|null Gebruikersdata als array, of null indien niet ingelogd.
|
||||||
|
*/
|
||||||
public function getCurrentUser(): ?array
|
public function getCurrentUser(): ?array
|
||||||
{
|
{
|
||||||
if (!$this->isAuthenticated()) {
|
if (!$this->isAuthenticated()) {
|
||||||
@@ -186,8 +361,14 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the role of the current user.
|
* Geeft de huidige rol van de ingelogde gebruiker.
|
||||||
* Returns the override role if set (for testing), otherwise the real role.
|
*
|
||||||
|
* Retourneert de override-rol indien ingesteld (voor test-doeleinden), anders
|
||||||
|
* de werkelijke rol.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Rol-sleutel.
|
||||||
*/
|
*/
|
||||||
public function getCurrentRole(): string
|
public function getCurrentRole(): string
|
||||||
{
|
{
|
||||||
@@ -198,7 +379,11 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the real role of the current user (ignoring any override).
|
* Geeft de werkelijke rol van de ingelogde gebruiker, eventuele override negerend.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string Rol-sleutel.
|
||||||
*/
|
*/
|
||||||
public function getRealRole(): string
|
public function getRealRole(): string
|
||||||
{
|
{
|
||||||
@@ -206,7 +391,11 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user has an active role override.
|
* Controleert of de ingelogde gebruiker een actieve rol-override heeft.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return bool True indien een override-rol is ingesteld.
|
||||||
*/
|
*/
|
||||||
public function hasRoleOverride(): bool
|
public function hasRoleOverride(): bool
|
||||||
{
|
{
|
||||||
@@ -214,8 +403,16 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Temporarily switch to another role for testing purposes.
|
* Wisselt tijdelijk naar een andere rol voor test-doeleinden.
|
||||||
* Only admin users can do this; the override cannot grant more than admin.
|
*
|
||||||
|
* Alleen werkelijke admins kunnen wisselen; admins kunnen niet wisselen naar
|
||||||
|
* 'admin' (zinloos) en enkel naar lagere rollen. Slaat de override op in de
|
||||||
|
* sessie. De actie wordt gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $role Doel-rol-sleutel.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function switchRole(string $role): array
|
public function switchRole(string $role): array
|
||||||
{
|
{
|
||||||
@@ -240,7 +437,13 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the role override back to the real admin role.
|
* Reset de rol-override terug naar de werkelijke admin-rol.
|
||||||
|
*
|
||||||
|
* Verwijdert de override uit de sessie indien aanwezig en logt de actie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function resetRole(): array
|
public function resetRole(): array
|
||||||
{
|
{
|
||||||
@@ -253,7 +456,15 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user has permission to access a route.
|
* Controleert of de huidige gebruiker een bepaalde route mag openen.
|
||||||
|
*
|
||||||
|
* Gebruikt de permissies van de huidige rol; de wildcard '*' geeft toegang
|
||||||
|
* tot alles. Onbekende rollen krijgen enkel 'dashboard' en 'logout'.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $route Route-sleutel die gecontroleerd moet worden.
|
||||||
|
* @return bool True indien de gebruiker de route mag benaderen.
|
||||||
*/
|
*/
|
||||||
public function hasPermission(string $route): bool
|
public function hasPermission(string $route): bool
|
||||||
{
|
{
|
||||||
@@ -268,7 +479,11 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available roles.
|
* Geeft alle beschikbare rollen met hun leesbare labels.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, string> Map van rol-sleutel naar label.
|
||||||
*/
|
*/
|
||||||
public static function getRoles(): array
|
public static function getRoles(): array
|
||||||
{
|
{
|
||||||
@@ -276,13 +491,28 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get role label.
|
* Geeft het leesbare label voor een rol.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $role Rol-sleutel.
|
||||||
|
* @return string Leesbaar label, of de rol-sleutel zelf indien onbekend.
|
||||||
*/
|
*/
|
||||||
public static function getRoleLabel(string $role): string
|
public static function getRoleLabel(string $role): string
|
||||||
{
|
{
|
||||||
return self::ROLE_LABELS[$role] ?? $role;
|
return self::ROLE_LABELS[$role] ?? $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft het huidige CSRF-token, of genereert een nieuw token indien afwezig.
|
||||||
|
*
|
||||||
|
* Slaat een cryptografisch willekeurig token op in de sessie en retourneert
|
||||||
|
* dit voor gebruik in admin-formulieren.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return string 64-tekens hexadecimaal CSRF-token.
|
||||||
|
*/
|
||||||
public function getCsrfToken(): string
|
public function getCsrfToken(): string
|
||||||
{
|
{
|
||||||
if (!isset($_SESSION['admin_csrf_token'])) {
|
if (!isset($_SESSION['admin_csrf_token'])) {
|
||||||
@@ -291,11 +521,31 @@ class AdminAuth
|
|||||||
return $_SESSION['admin_csrf_token'];
|
return $_SESSION['admin_csrf_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifieert een meegegeven CSRF-token tegen het token in de sessie.
|
||||||
|
*
|
||||||
|
* Gebruikt hash_equals om timing-attacks te voorkomen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $token Token uit het formulier of verzoek.
|
||||||
|
* @return bool True indien het token overeenkomt met het sessie-token.
|
||||||
|
*/
|
||||||
public function verifyCsrf(string $token): bool
|
public function verifyCsrf(string $token): bool
|
||||||
{
|
{
|
||||||
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeneert het CSRF-token in de sessie.
|
||||||
|
*
|
||||||
|
* Maakt een nieuw cryptografisch willekeurig token aan, nuttig na
|
||||||
|
* privilege-wijzigingen of na het verwerken van een formulier.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function regenerateCsrfToken(): void
|
public function regenerateCsrfToken(): void
|
||||||
{
|
{
|
||||||
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||||
@@ -303,6 +553,16 @@ class AdminAuth
|
|||||||
|
|
||||||
// --- User Management ---
|
// --- User Management ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geeft alle gebruikers met hun profiel- en rolinformatie.
|
||||||
|
*
|
||||||
|
* Levert een array geïndexeerd op gebruikersnaam, aangevuld met het
|
||||||
|
* leesbare rol-label en eventuele profielvelden.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, array> Map van gebruikersnaam naar gebruikersdata.
|
||||||
|
*/
|
||||||
public function getUsers(): array
|
public function getUsers(): array
|
||||||
{
|
{
|
||||||
$users = [];
|
$users = [];
|
||||||
@@ -321,6 +581,23 @@ class AdminAuth
|
|||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voegt een nieuwe gebruiker toe aan de admin-configuratie.
|
||||||
|
*
|
||||||
|
* Controleert op dubbele gebruikersnamen, minimum wachtwoordlengte (8) en
|
||||||
|
* geldigheid van de rol. Het wachtwoord wordt opgeslagen als bcrypt-hash.
|
||||||
|
* Wijzigingen worden direct weggeschreven en gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $password Wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @param string $role Rol-sleutel. Default 'admin'.
|
||||||
|
* @param string $email E-mailadres van de gebruiker. Default ''.
|
||||||
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||||
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
{
|
{
|
||||||
if ($this->findUser($username)) {
|
if ($this->findUser($username)) {
|
||||||
@@ -348,7 +625,18 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the profile (email, author_name, author_email) of a user.
|
* Werkt de profielvelden (email, author_name, author_email) van een gebruiker bij.
|
||||||
|
*
|
||||||
|
* Zoekt de gebruiker op naam en overschrijft de profielvelden, slaat de
|
||||||
|
* configuratie op en logt de wijziging.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $email E-mailadres. Default ''.
|
||||||
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
||||||
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
|
||||||
{
|
{
|
||||||
@@ -366,8 +654,17 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the role of an existing user.
|
* Wijzigt de rol van een bestaande gebruiker.
|
||||||
* A user can never change their own role (security guard).
|
*
|
||||||
|
* Een gebruiker kan nooit zijn eigen rol wijzigen (security guard). De rol
|
||||||
|
* moet een bekende sleutel uit ROLE_PERMISSIONS zijn. Wijzigingen worden
|
||||||
|
* opgeslagen en gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $role Nieuwe rol-sleutel.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
*/
|
*/
|
||||||
public function changeRole(string $username, string $role): array
|
public function changeRole(string $username, string $role): array
|
||||||
{
|
{
|
||||||
@@ -388,6 +685,17 @@ class AdminAuth
|
|||||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwijdert een gebruiker uit de admin-configuratie.
|
||||||
|
*
|
||||||
|
* Een gebruiker kan zichzelf niet verwijderen. De gebruikerslijst wordt
|
||||||
|
* gefilterd, opgeslagen en de actie gelogd.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function deleteUser(string $username): array
|
public function deleteUser(string $username): array
|
||||||
{
|
{
|
||||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||||
@@ -403,6 +711,18 @@ class AdminAuth
|
|||||||
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zet een nieuw wachtwoord voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Controleert de minimum wachtwoordlengte (8), hasht het wachtwoord met de
|
||||||
|
* standaard PHP-hash en slaat de wijziging op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function changePassword(string $username, string $newPassword): array
|
public function changePassword(string $username, string $newPassword): array
|
||||||
{
|
{
|
||||||
if (strlen($newPassword) < 8) {
|
if (strlen($newPassword) < 8) {
|
||||||
@@ -419,6 +739,19 @@ class AdminAuth
|
|||||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wijzigt het eigen wachtwoord na verificatie van het huidige wachtwoord.
|
||||||
|
*
|
||||||
|
* Verifieert eerst het huidige wachtwoord tegen de opgeslagen hash, controleert
|
||||||
|
* de lengte van het nieuwe wachtwoord (minimaal 8) en slaat de nieuwe hash op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @param string $currentPassword Huidig wachtwoord (plaintext).
|
||||||
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
||||||
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
||||||
|
*/
|
||||||
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
|
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
|
||||||
{
|
{
|
||||||
$user = $this->findUser($username);
|
$user = $this->findUser($username);
|
||||||
@@ -444,6 +777,14 @@ class AdminAuth
|
|||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoekt een gebruiker op gebruikersnaam in de admin-configuratie.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array|null Gebruikersdata als array, of null indien niet gevonden.
|
||||||
|
*/
|
||||||
private function findUser(string $username): ?array
|
private function findUser(string $username): ?array
|
||||||
{
|
{
|
||||||
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
||||||
@@ -454,6 +795,18 @@ class AdminAuth
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controleert of een gebruiker momenteel is vergrendeld door brute-force protection.
|
||||||
|
*
|
||||||
|
* Vergelijkt het aantal mislukte pogingen en de verstreken tijd sinds de
|
||||||
|
* laatste poging met de geconfigureerde limieten. Verloopt de lockout dan
|
||||||
|
* worden de pogingen gewist.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return array Resultaat met keys 'locked' (bool) en optioneel 'remaining' (int, seconden).
|
||||||
|
*/
|
||||||
private function checkLockout(string $username): array
|
private function checkLockout(string $username): array
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -477,6 +830,17 @@ class AdminAuth
|
|||||||
return ['locked' => false];
|
return ['locked' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registreert een mislukte inlogpoging voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Verhoogt de teller en actualiseert de timestamp van de laatste poging,
|
||||||
|
* waarna de gegevens worden weggeschreven naar het lockout-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function recordFailedAttempt(string $username): void
|
private function recordFailedAttempt(string $username): void
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -488,6 +852,16 @@ class AdminAuth
|
|||||||
file_put_contents($this->lockFile, json_encode($attempts));
|
file_put_contents($this->lockFile, json_encode($attempts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wist alle mislukte inlogpogingen voor een gebruiker.
|
||||||
|
*
|
||||||
|
* Verwijdert de gebruiker uit het lockout-bestand en slaat dit op.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function clearFailedAttempts(string $username): void
|
private function clearFailedAttempts(string $username): void
|
||||||
{
|
{
|
||||||
$attempts = $this->getFailedAttempts();
|
$attempts = $this->getFailedAttempts();
|
||||||
@@ -496,13 +870,28 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all failed login attempts for a user (public, used by CLI reset).
|
* Wist de lockout voor een gebruiker (publiek, gebruikt door CLI reset).
|
||||||
|
*
|
||||||
|
* Wrapper rond clearFailedAttempts die ook van buiten de class kan worden
|
||||||
|
* aangeroepen, bijvoorbeeld door een CLI-tool voor het resetten van vergrendelingen.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $username Gebruikersnaam.
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function clearLockout(string $username): void
|
public function clearLockout(string $username): void
|
||||||
{
|
{
|
||||||
$this->clearFailedAttempts($username);
|
$this->clearFailedAttempts($username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leest alle mislukte inlogpogingen uit het lockout-bestand.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @return array<string, array{count: int, last_attempt: int}> Map van gebruikersnaam naar poging-data.
|
||||||
|
*/
|
||||||
private function getFailedAttempts(): array
|
private function getFailedAttempts(): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->lockFile)) {
|
if (!file_exists($this->lockFile)) {
|
||||||
@@ -512,6 +901,18 @@ class AdminAuth
|
|||||||
return is_array($data) ? $data : [];
|
return is_array($data) ? $data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schrijft een bericht naar het logbestand met timestamp, level en IP-adres.
|
||||||
|
*
|
||||||
|
* Maakt de logdirectory aan indien nodig. Het IP-adres wordt via RequestLogger
|
||||||
|
* bepaald indien beschikbaar, anders via REMOTE_ADDR met een fallback.
|
||||||
|
*
|
||||||
|
* @since 2.6.5
|
||||||
|
*
|
||||||
|
* @param string $level Log-niveau (bijv. 'info', 'warning').
|
||||||
|
* @param string $message Bericht dat gelogd moet worden.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
private function log(string $level, string $message): void
|
private function log(string $level, string $message): void
|
||||||
{
|
{
|
||||||
$logFile = $this->config['log_file'];
|
$logFile = $this->config['log_file'];
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"admi":{"count":1,"last_attempt":1771257322}}
|
{"admi":{"count":1,"last_attempt":1771257322},"":{"count":4,"last_attempt":1787141227}}
|
||||||
@@ -27,6 +27,14 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if generated_password %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle-fill"></i> Eerste installatie</h6>
|
||||||
|
<p class="mb-2">Er is nog geen <code>admin.json</code> aanwezig. Een nieuw wachtwoord is gegenereerd voor de gebruiker <strong>admin</strong>:</p>
|
||||||
|
<p class="mb-2"><code class="fs-5">{{ generated_password }}</code></p>
|
||||||
|
<p class="mb-0 small">Log in met dit wachtwoord en wijzig het direct na login. Sla het veilig op; het wordt niet opnieuw getoond.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<form method="POST" action="/admin/login">
|
<form method="POST" action="/admin/login">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -13,24 +13,37 @@
|
|||||||
.editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); 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-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 { flex: 0 0 auto; }
|
||||||
|
.editor-tree .tree-chevron-btn { background: transparent; border: 0; color: inherit; padding: .2rem .4rem; line-height: 1.4; flex: 0 0 auto; cursor: pointer; }
|
||||||
|
.editor-tree .tree-chevron-btn:hover { color: var(--bs-primary, #0d6efd); }
|
||||||
|
.editor-tree .tree-name { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.editor-tree .tree-name.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
|
||||||
|
.editor-tree .tree-root-link { display: flex; align-items: center; gap: .3rem; padding: .25rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; font-weight: 600; }
|
||||||
|
.editor-tree .tree-root-link:hover { background-color: rgba(13,110,253,.08); }
|
||||||
|
.editor-tree .tree-root-link.is-selected { background-color: var(--bs-primary-bg-subtle, #cfe2ff); }
|
||||||
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
|
||||||
.editor-tree .badge-ext { font-size: .6rem; margin-left: 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 .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-move-btn,
|
||||||
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
|
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; background: transparent; border: 0; }
|
||||||
.editor-tree .tree-delete-btn:hover { color: #b02a37; }
|
.editor-tree .tree-move-btn:hover,
|
||||||
.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-delete-btn:hover { color: #0d6efd; }
|
||||||
.editor-tree .tree-move-btn:hover { color: #0d6efd; }
|
.editor-tree .tree-delete-btn:hover { color: #dc3545; }
|
||||||
.editor-tree li > .tree-link { padding-right: 3.5rem; }
|
.editor-tree .tree-file-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; opacity: 0; transition: opacity .15s; }
|
||||||
|
.editor-tree li:hover > .tree-file-actions { opacity: 1; }
|
||||||
|
.editor-tree li > .tree-link { padding-right: 4rem; }
|
||||||
|
/* Drag-and-drop states */
|
||||||
|
.editor-tree li.tree-dragging { opacity: .5; }
|
||||||
|
.editor-tree .tree-drag-over { background-color: rgba(13,110,253,.18) !important; outline: 2px dashed var(--bs-primary, #0d6efd); outline-offset: -2px; border-radius: .25rem; }
|
||||||
|
/* Toast */
|
||||||
|
.tree-toast { border-radius: .375rem; }
|
||||||
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
|
||||||
/* Directory action buttons (new file/dir, rename, delete) — content editor only */
|
/* Directory action buttons (new file/dir, rename) — content editor only */
|
||||||
.tree-dir-row { position: relative; }
|
.tree-dir-row { position: relative; display: flex; align-items: center; }
|
||||||
.tree-dir-row .tree-toggle { padding-right: 7rem; }
|
.tree-dir-row > .tree-name { padding-right: 3rem; }
|
||||||
|
.tree-dir-row > .tree-toggle { padding-right: 5rem; }
|
||||||
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
|
.tree-dir-actions { 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 { color: #6c757d; }
|
||||||
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.editor-layout { flex-direction: column; }
|
.editor-layout { flex-direction: column; }
|
||||||
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
|
||||||
|
|||||||
@@ -6,25 +6,33 @@
|
|||||||
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
|
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
|
||||||
- relFile: currently selected relative file path (for active highlight + auto-expand)
|
- relFile: currently selected relative file path (for active highlight + auto-expand)
|
||||||
- editableExts: array of editable extensions (shown as .EXT badge)
|
- 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')
|
- 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'
|
- 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)
|
- 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'
|
- treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move'
|
||||||
- treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or ''
|
- 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)
|
- treeDirActions: boolean; show per-folder new-file/new-dir/rename buttons (default true)
|
||||||
|
- treeFileMove: boolean; show per-file move/rename pencil button (default true)
|
||||||
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
|
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
|
||||||
|
- treeDirSelectRoute: if set, folder names are links to ?dir=<path> (map-detail pane)
|
||||||
|
- treeDirRenameRoute: if set, show a rename pencil per folder
|
||||||
|
- treeDeleteBase: POST URL for file deletion (scope-specific)
|
||||||
|
- treeScope: 'content' | 'theme' | 'plugin' (for tree-interactions.js)
|
||||||
|
- treeMoveUrl: AJAX endpoint for drag-drop move (e.g. '/admin/tree-move')
|
||||||
|
- treeScopeParam: extra query param string (e.g. 'plugin=MyPlugin&' or 'theme=default&' or '')
|
||||||
|
|
||||||
Icon maps are chosen automatically from the node extension. #}
|
Icon maps are chosen automatically from the node extension. #}
|
||||||
{% macro tree(nodes, opts) %}
|
{% macro tree(nodes, opts) %}
|
||||||
{% import _self as macros %}
|
{% import _self as macros %}
|
||||||
<ul class="editor-tree">
|
<ul class="editor-tree">
|
||||||
{% for n in nodes %}
|
{% for n in nodes %}
|
||||||
{% set isActive = n.path == opts.relFile %}
|
{# Een bestand is alleen actief wanneer er geen map geselecteerd is
|
||||||
{% set containsActive = n.is_dir and opts.relFile starts with (n.path ~ '/') %}
|
(een map-selectie opent het map-detail paneel, geen bestand). #}
|
||||||
|
{% set isActive = n.path == opts.relFile and opts.selectedDir|default('') == '' %}
|
||||||
|
{# Markeer de oudermap "bevat actieve selectie" voor auto-uitklappen:
|
||||||
|
bij een geselecteerd bestand OF een geselecteerde (sub)map. #}
|
||||||
|
{% set activePath = opts.selectedDir|default('') != '' ? opts.selectedDir : opts.relFile %}
|
||||||
|
{% set containsActive = n.is_dir and activePath != '' and activePath starts with (n.path ~ '/') %}
|
||||||
{% if n.is_dir %}
|
{% if n.is_dir %}
|
||||||
{% set icon = 'bi-folder-fill text-warning' %}
|
{% set icon = 'bi-folder-fill text-warning' %}
|
||||||
{% elseif n.extension == 'md' %}
|
{% elseif n.extension == 'md' %}
|
||||||
@@ -48,37 +56,42 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% set icon = 'bi-file text-muted' %}
|
{% set icon = 'bi-file text-muted' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li data-path="{{ n.path }}" data-isdir="{{ n.is_dir ? '1' : '0' }}">
|
||||||
{% if n.is_dir %}
|
{% if n.is_dir %}
|
||||||
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
|
||||||
<div class="tree-dir-row">
|
<div class="tree-dir-row" data-dir="{{ n.path }}">
|
||||||
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
|
{# Chevron: expand/collapse only (zonder navigatie) #}
|
||||||
data-bs-toggle="collapse"
|
<button type="button" class="tree-chevron-btn" data-bs-toggle="collapse" data-bs-target="#{{ collapseId }}" aria-expanded="{{ containsActive ? 'true' : 'false' }}" title="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}" aria-label="{{ ta.expand_collapse|default('Uitklappen/inklappen') }}">
|
||||||
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 {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
|
||||||
|
</button>
|
||||||
|
{# Mapnaam: selecteer de map. Voor content gaat dit naar ?dir= (map-detail paneel rechts);
|
||||||
|
voor plugins/themes is het een niet-navigerende toggle (legacy gedrag). #}
|
||||||
|
{% if opts.treeDirSelectRoute %}
|
||||||
|
<a href="{{ opts.treeDirSelectRoute }}{% if '?' in opts.treeDirSelectRoute %}&{% else %}?{% endif %}dir={{ n.path|url_encode }}" class="tree-link tree-name {{ containsActive ? 'is-active' : '' }} {{ opts.selectedDir == n.path ? 'is-selected' : '' }}" title="{{ n.path }}">
|
||||||
|
<i class="bi {{ icon }}"></i>
|
||||||
|
<span class="text-truncate">{{ n.name }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}" data-bs-toggle="collapse" data-bs-target="#{{ collapseId }}" aria-expanded="{{ containsActive ? 'true' : 'false' }}" title="{{ n.path }}">
|
||||||
<i class="bi {{ icon }}"></i>
|
<i class="bi {{ icon }}"></i>
|
||||||
<span class="text-truncate">{{ n.name }}</span>
|
<span class="text-truncate">{{ n.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if opts.treeDirActions %}
|
{% if opts.treeDirActions %}
|
||||||
<span class="tree-dir-actions">
|
<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 }}">
|
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="{{ opts.newFileModalId|default('#newFileModal') }}" data-in-dir="{{ n.path }}">
|
||||||
<i class="bi bi-file-earmark-plus"></i>
|
<i class="bi bi-file-earmark-plus"></i>
|
||||||
</button>
|
</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 }}">
|
{% if opts.newDirModalId is defined and opts.newDirModalId %}
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="{{ opts.newDirModalId }}" data-in-dir="{{ n.path }}">
|
||||||
<i class="bi bi-folder-plus"></i>
|
<i class="bi bi-folder-plus"></i>
|
||||||
</button>
|
</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') }}">
|
{% endif %}
|
||||||
|
{% if opts.treeDirRenameRoute %}
|
||||||
|
<a href="{{ opts.treeDirRenameRoute }}{% if '?' in opts.treeDirRenameRoute %}&{% else %}?{% endif %}dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</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.') }}')">
|
{% endif %}
|
||||||
<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>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,22 +112,20 @@
|
|||||||
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
|
<span class="tree-file-actions">
|
||||||
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
|
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
|
||||||
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
|
{% set showFileMove = opts.treeFileMove is defined ? opts.treeFileMove : true %}
|
||||||
<i class="bi bi-pencil"></i>
|
{% if showFileMove %}
|
||||||
</a>
|
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
|
||||||
<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?') }}')">
|
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen/Verplaatsen') }}" aria-label="{{ ta.rename|default('Hernoemen/Verplaatsen') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
|
<i class="bi bi-pencil"></i>
|
||||||
{% if opts.treeDeleteName %}
|
</a>
|
||||||
<input type="hidden" name="{{ opts.treeDeleteName }}" value="{{ opts.treeDeleteValue }}">
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="hidden" name="file" value="{{ n.path }}">
|
<button type="button" class="btn btn-link btn-sm p-0 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}" data-path="{{ n.path }}">
|
||||||
<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>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{% endif %}
|
||||||
{% endif %}
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -129,29 +140,48 @@
|
|||||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
<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>
|
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ treeHeader|default('Bestanden') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-2" id="editorFileTree">
|
<div class="card-body p-2" id="editorFileTree"
|
||||||
|
data-csrf="{{ csrf_token|default('') }}"
|
||||||
|
data-scope="{{ treeScope|default('content') }}"
|
||||||
|
data-move-url="{{ treeMoveUrl|default('') }}"
|
||||||
|
data-delete-base="{{ treeDeleteBase|default('') }}"
|
||||||
|
data-new-file-modal="{{ newFileModalId|default('#newFileModal') }}"
|
||||||
|
data-new-dir-modal="{{ newDirModalId|default('#newDirModal') }}"
|
||||||
|
data-scope-param="{{ treeScopeParam|default('') }}"
|
||||||
|
data-edit-base="{{ treeRouteBase|default('') }}">
|
||||||
|
{% if treeDirSelectRoute is defined and treeDirSelectRoute is not empty %}
|
||||||
|
{# Klikbare content-root: selecteert de root map (?dir= leeg).
|
||||||
|
data-dir="" maakt de root ook een drag-drop doel (lege string = base dir). #}
|
||||||
|
<a href="{{ treeDirSelectRoute }}" class="tree-link tree-root-link {{ selectedDir|default('') == '' ? 'is-selected' : '' }}" data-dir="" title="content">
|
||||||
|
<i class="bi bi-folder2-open"></i>
|
||||||
|
<span class="text-truncate">{{ treeRootLabel|default('content') }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if files is empty %}
|
{% if files is empty %}
|
||||||
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
|
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ tree_macros.tree(files, {
|
{{ tree_macros.tree(files, {
|
||||||
'relFile': relFile,
|
'relFile': relFile,
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
'editableExts': editableExts,
|
'editableExts': editableExts,
|
||||||
'csrfToken': csrf_token,
|
|
||||||
'treeIdPrefix': treeIdPrefix,
|
'treeIdPrefix': treeIdPrefix,
|
||||||
'treeRouteBase': treeRouteBase,
|
'treeRouteBase': treeRouteBase,
|
||||||
'treeRouteParams': treeRouteParams,
|
'treeRouteParams': treeRouteParams,
|
||||||
'treeDeleteRoute': treeDeleteRoute,
|
|
||||||
'treeDeleteName': treeDeleteName|default(''),
|
|
||||||
'treeDeleteValue': treeDeleteValue|default(''),
|
|
||||||
'treeMoveRoute': treeMoveRoute,
|
'treeMoveRoute': treeMoveRoute,
|
||||||
'treeMoveParams': treeMoveParams,
|
'treeMoveParams': treeMoveParams,
|
||||||
'treeDirActions': treeDirActions|default(false),
|
'treeDirActions': treeDirActions is defined ? treeDirActions : true,
|
||||||
|
'treeDirSelectRoute': treeDirSelectRoute|default(''),
|
||||||
|
'treeDirRenameRoute': treeDirRenameRoute|default(''),
|
||||||
|
'treeFileMove': treeFileMove is defined ? treeFileMove : true,
|
||||||
'treeHideActionsFor': treeHideActionsFor|default([]),
|
'treeHideActionsFor': treeHideActionsFor|default([]),
|
||||||
|
'newFileModalId': newFileModalId|default('#newFileModal'),
|
||||||
|
'newDirModalId': newDirModalId|default(''),
|
||||||
}) }}
|
}) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/admin/assets/js/tree-interactions.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var tree = document.getElementById('editorFileTree');
|
var tree = document.getElementById('editorFileTree');
|
||||||
@@ -187,5 +217,10 @@
|
|||||||
var btn = findToggle(c);
|
var btn = findToggle(c);
|
||||||
if (btn) expand(btn);
|
if (btn) expand(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make all tree <li> nodes draggable (for drag-and-drop move)
|
||||||
|
Array.prototype.forEach.call(tree.querySelectorAll('li[data-path]'), function (li) {
|
||||||
|
li.setAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -60,8 +60,9 @@
|
|||||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</form>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
<button type="submit" form="editor-form" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
{% if isEditable %}
|
{% if isEditable %}
|
||||||
@@ -70,8 +71,14 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
|
||||||
|
<form method="POST" action="/admin/content-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="file" value="{{ file }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -60,23 +60,105 @@
|
|||||||
'files': files,
|
'files': files,
|
||||||
'relFile': relFile,
|
'relFile': relFile,
|
||||||
'editableExts': editableExts,
|
'editableExts': editableExts,
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'treeIdPrefix': 'content-tree',
|
'treeIdPrefix': 'content-tree',
|
||||||
'treeHeader': ta.content_files|default('Content bestanden'),
|
'treeHeader': ta.content_files|default('Content bestanden'),
|
||||||
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
|
||||||
'treeRouteBase': '/admin/content',
|
'treeRouteBase': '/admin/content',
|
||||||
'treeRouteParams': '',
|
'treeRouteParams': '',
|
||||||
'treeDeleteRoute': '/admin/content-file-delete',
|
|
||||||
'treeDeleteName': '',
|
|
||||||
'treeDeleteValue': '',
|
|
||||||
'treeMoveRoute': '/admin/content-file-move',
|
'treeMoveRoute': '/admin/content-file-move',
|
||||||
'treeMoveParams': '',
|
'treeMoveParams': '',
|
||||||
'treeDirActions': true,
|
'treeDirActions': true,
|
||||||
|
'treeDirSelectRoute': '/admin/content',
|
||||||
|
'treeDirRenameRoute': '',
|
||||||
|
'treeFileMove': false,
|
||||||
'treeHideActionsFor': [],
|
'treeHideActionsFor': [],
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'treeRootLabel': 'content',
|
||||||
|
'treeScope': 'content',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/content-file-delete',
|
||||||
|
'treeScopeParam': '',
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor / map-detail main panel #}
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# ── Map detail paneel ( rechts) ── tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-folder2-open"></i> content</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? 'content' : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">content/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Mapnaam bewerkbaar + Opslaan = hernoem (geen apart potlood-icoon in de boom meer) #}
|
||||||
|
<form method="POST" action="/admin/content?{% if selectedDir %}dir={{ selectedDir|url_encode }}{% endif %}" id="dir-rename-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="rename_dir">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<label for="dirname" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" id="dirname" name="newname" value="{{ selectedDirIsRoot ? '' : selectedDirName }}" placeholder="{{ ta.folder_name_placeholder|default('mapnaam') }}" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||||
|
<button type="submit" class="btn btn-primary" {{ selectedDirIsRoot ? 'disabled' : '' }}>
|
||||||
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">{{ ta.folder_rename_help|default('Bewerk de mapnaam en klik opslaan om te hernoemen.') }}</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload — opereren op de geselecteerde map #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewFileBtn">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}" id="mapPaneelNewDirBtn">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm" id="mapPaneelUploadBtn">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
|
</button>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Korte inhoud-samenvatting van de geselecteerde map #}
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
@@ -149,15 +231,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
<button type="submit" form="editor-form" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
|
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ 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>
|
||||||
|
<form method="POST" action="/admin/content-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body text-center py-5 text-muted">
|
<div class="card-body text-center py-5 text-muted">
|
||||||
@@ -165,11 +254,12 @@
|
|||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
{{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
{{ ta.content_not_editable|default('Dit bestandstype kan niet in de editor bewerkt worden.') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ta.content_select_file|default('Selecteer een bestand uit de zijbalk om te bewerken.') }}
|
{{ ta.content_select_file|default('Selecteer een bestand of map uit de zijbalk.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,7 +270,7 @@
|
|||||||
<form method="POST" action="/admin/content">
|
<form method="POST" action="/admin/content">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="action" value="new_file">
|
<input type="hidden" name="action" value="new_file">
|
||||||
<input type="hidden" name="in_dir" id="newFileDir" value="">
|
<input type="hidden" name="in_dir" id="newFileDir" value="" data-in-dir-field>
|
||||||
<div class="modal-header">
|
<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>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
@@ -217,7 +307,7 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<form method="POST" action="/admin/content-dir-create-in">
|
<form method="POST" action="/admin/content-dir-create-in">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="in_dir" id="newDirInDir" value="">
|
<input type="hidden" name="in_dir" id="newDirInDir" value="" data-in-dir-field>
|
||||||
<div class="modal-header">
|
<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>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
@@ -239,36 +329,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Pass the in-dir value from the tree button to the modal hidden fields
|
// Mapselectie + upload/new file/new dir binding aan de geselecteerde map.
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var newFileModal = document.getElementById('newFileModal');
|
var newFileModal = document.getElementById('newFileModal');
|
||||||
var newDirModal = document.getElementById('newDirModal');
|
var newDirModal = document.getElementById('newDirModal');
|
||||||
var newFileBtnTop = document.getElementById('newFileBtnTop');
|
var newFileBtnTop = document.getElementById('newFileBtnTop');
|
||||||
var newDirBtnTop = document.getElementById('newDirBtnTop');
|
var newDirBtnTop = document.getElementById('newDirBtnTop');
|
||||||
|
var uploadDir = document.getElementById('contentUploadDir');
|
||||||
|
|
||||||
// Track the selected directory for the top toolbar buttons
|
// Track the selected directory (server-side via ?dir=). This drives the
|
||||||
|
// top toolbar buttons and the upload form target.
|
||||||
var selectedDir = '{{ selectedDir|default('') }}';
|
var selectedDir = '{{ selectedDir|default('') }}';
|
||||||
function updateTopButtons(dir) {
|
function updateTopButtons(dir) {
|
||||||
selectedDir = dir || '';
|
selectedDir = dir || '';
|
||||||
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
|
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||||
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
|
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
|
||||||
|
if (uploadDir) uploadDir.value = selectedDir;
|
||||||
}
|
}
|
||||||
|
updateTopButtons(selectedDir);
|
||||||
|
|
||||||
// Make folder rows selectable: clicking the folder name selects it
|
|
||||||
var tree = document.getElementById('editorFileTree');
|
var tree = document.getElementById('editorFileTree');
|
||||||
if (tree) {
|
if (tree) {
|
||||||
|
// Chevron-kliks mogen niet doorbubbellen naar de mapnaam-link (die navigeert).
|
||||||
tree.addEventListener('click', function (e) {
|
tree.addEventListener('click', function (e) {
|
||||||
var toggle = e.target.closest('.tree-toggle');
|
var chevronBtn = e.target.closest('.tree-chevron-btn');
|
||||||
if (!toggle) return;
|
if (chevronBtn) { e.stopPropagation(); }
|
||||||
// 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');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +363,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
|
||||||
var hidden = document.getElementById('newFileDir');
|
var hidden = document.getElementById('newFileDir');
|
||||||
if (hidden) hidden.value = inDir || '';
|
if (hidden) hidden.value = inDir || '';
|
||||||
// Show the in-dir prefix as a hint in the placeholder
|
|
||||||
var input = document.getElementById('newFilenameInput');
|
var input = document.getElementById('newFilenameInput');
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -295,15 +379,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the initially selected folder (based on the opened file's directory)
|
|
||||||
if (selectedDir && tree) {
|
|
||||||
var initialBtn = tree.querySelector('.tree-newfile-btn[data-in-dir="' + selectedDir + '"]');
|
|
||||||
if (initialBtn) {
|
|
||||||
var initialToggle = initialBtn.closest('.tree-dir-row').querySelector('.tree-toggle');
|
|
||||||
if (initialToggle) initialToggle.classList.add('is-selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout select → update frontmatter (spiegel van content-edit.twig)
|
// Layout select → update frontmatter (spiegel van content-edit.twig)
|
||||||
var layoutSelect = document.getElementById('layout');
|
var layoutSelect = document.getElementById('layout');
|
||||||
if (layoutSelect) {
|
if (layoutSelect) {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
<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') }}
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.plugin_new_file|default('Nieuw bestand') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -51,23 +54,85 @@
|
|||||||
'files': files,
|
'files': files,
|
||||||
'relFile': relFile,
|
'relFile': relFile,
|
||||||
'editableExts': editableExts,
|
'editableExts': editableExts,
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'treeIdPrefix': 'plugin-tree',
|
'treeIdPrefix': 'plugin-tree',
|
||||||
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
|
||||||
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
|
||||||
'treeRouteBase': '/admin/plugins-edit',
|
'treeRouteBase': '/admin/plugins-edit',
|
||||||
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
'treeDeleteRoute': '/admin/plugins-file-delete',
|
|
||||||
'treeDeleteName': 'plugin',
|
|
||||||
'treeDeleteValue': pluginName,
|
|
||||||
'treeMoveRoute': '/admin/plugins-file-move',
|
'treeMoveRoute': '/admin/plugins-file-move',
|
||||||
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
'treeDirActions': false,
|
'treeDirActions': true,
|
||||||
'treeHideActionsFor': [],
|
'treeHideActionsFor': [],
|
||||||
|
'treeDirSelectRoute': '/admin/plugins-edit?plugin=' ~ pluginName|url_encode,
|
||||||
|
'treeDirRenameRoute': '/admin/plugins-dir-rename-in?plugin=' ~ pluginName|url_encode,
|
||||||
|
'treeRootLabel': pluginName,
|
||||||
|
'treeScope': 'plugin',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/plugins-file-delete',
|
||||||
|
'treeScopeParam': 'plugin=' ~ pluginName|url_encode ~ '&',
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ pluginName }}</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? pluginName : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ pluginName }}/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<form method="POST" action="/admin/plugins-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
@@ -97,13 +162,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" form="editor-form" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/plugins" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
<form method="POST" action="/admin/plugins-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body text-center py-5 text-muted">
|
<div class="card-body text-center py-5 text-muted">
|
||||||
@@ -116,6 +189,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,6 +200,7 @@
|
|||||||
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="action" value="new_file">
|
<input type="hidden" name="action" value="new_file">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
<div class="modal-header">
|
<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>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
@@ -145,6 +220,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# New directory modal #}
|
||||||
|
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/admin/plugins-dir-create-in">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="plugin" value="{{ pluginName }}">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="newDirModalLabel"><i class="bi bi-folder-plus"></i> {{ ta.new_folder_title|default('Nieuwe map aanmaken') }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dirnameInput" class="form-label">{{ ta.folder_name|default('Mapnaam') }}</label>
|
||||||
|
<input type="text" class="form-control" id="dirnameInput" name="dirname" required autofocus>
|
||||||
|
<div class="form-text">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal">
|
<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') }}
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.theme_new_file|default('Nieuw bestand') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
|
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -64,23 +67,88 @@
|
|||||||
'files': files,
|
'files': files,
|
||||||
'relFile': relFile,
|
'relFile': relFile,
|
||||||
'editableExts': editableExts,
|
'editableExts': editableExts,
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'treeIdPrefix': 'theme-tree',
|
'treeIdPrefix': 'theme-tree',
|
||||||
'treeHeader': ta.theme_files|default('Thema bestanden'),
|
'treeHeader': ta.theme_files|default('Thema bestanden'),
|
||||||
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
|
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
|
||||||
'treeRouteBase': '/admin/theme-edit',
|
'treeRouteBase': '/admin/theme-edit',
|
||||||
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
|
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
'treeDeleteRoute': '/admin/theme-file-delete',
|
|
||||||
'treeDeleteName': 'theme',
|
|
||||||
'treeDeleteValue': themeName,
|
|
||||||
'treeMoveRoute': '/admin/theme-file-move',
|
'treeMoveRoute': '/admin/theme-file-move',
|
||||||
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
'treeDirActions': false,
|
'treeDirActions': true,
|
||||||
'treeHideActionsFor': ['theme.json'],
|
'treeHideActionsFor': ['theme.json'],
|
||||||
|
'treeDirSelectRoute': '/admin/theme-edit?theme=' ~ themeName|url_encode,
|
||||||
|
'treeDirRenameRoute': '/admin/theme-dir-rename-in?theme=' ~ themeName|url_encode,
|
||||||
|
'treeRootLabel': themeName,
|
||||||
|
'treeScope': 'theme',
|
||||||
|
'treeMoveUrl': '/admin/tree-move',
|
||||||
|
'treeDeleteBase': '/admin/theme-file-delete',
|
||||||
|
'treeScopeParam': 'theme=' ~ themeName|url_encode ~ '&',
|
||||||
|
'selectedDir': selectedDir|default(''),
|
||||||
|
'newFileModalId': '#newFileModal',
|
||||||
|
'newDirModalId': '#newDirModal',
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
{# Editor main panel #}
|
{# Editor main panel #}
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
|
{% if showMapPaneel %}
|
||||||
|
{# Map detail paneel — tonen wanneer een map is geselecteerd #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}"><i class="bi bi-folder2-open"></i> {{ themeName }}</a></li>
|
||||||
|
{% if selectedDir %}
|
||||||
|
{% set crumbPath = '' %}
|
||||||
|
{% for part in selectedDir|split('/') %}
|
||||||
|
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ part : part %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ part }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/theme-edit?theme={{ themeName|url_encode }}&dir={{ crumbPath|url_encode }}">{{ part }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning"></i>
|
||||||
|
<span class="fw-semibold">{{ selectedDirIsRoot ? themeName : selectedDirName }}</span>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<span class="badge bg-light text-dark ms-1">{{ themeName }}/{{ selectedDir }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{# Map acties: nieuw bestand / nieuwe map / upload / verwijder #}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir }}">
|
||||||
|
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#themeUploadForm">
|
||||||
|
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
|
||||||
|
</button>
|
||||||
|
{% if not selectedDirIsRoot %}
|
||||||
|
<form method="POST" action="/admin/theme-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||||
|
<input type="hidden" name="dir" value="{{ selectedDir }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-files"></i>
|
||||||
|
{{ selectedDirCounts.files }} {{ ta.files|default('bestanden') }},
|
||||||
|
{{ selectedDirCounts.dirs }} {{ ta.subfolders|default('submappen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
{% if relFile %}
|
{% if relFile %}
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
@@ -110,13 +178,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" form="editor-form" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
|
||||||
</button>
|
</button>
|
||||||
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
<a href="/admin/theme" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
|
||||||
|
{% if relFile != 'theme.json' %}
|
||||||
|
<form method="POST" action="/admin/theme-file-delete" class="ms-auto" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="theme" value="{{ themeName }}">
|
||||||
|
<input type="hidden" name="file" value="{{ relFile }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
|
||||||
|
<i class="bi bi-trash"></i> {{ ta.delete|default('Verwijderen') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body text-center py-5 text-muted">
|
<div class="card-body text-center py-5 text-muted">
|
||||||
@@ -129,6 +207,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}{# end showMapPaneel #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +218,7 @@
|
|||||||
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}">
|
<form method="POST" action="/admin/theme-edit?theme={{ themeName|url_encode }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="action" value="new_file">
|
<input type="hidden" name="action" value="new_file">
|
||||||
|
<input type="hidden" name="in_dir" value="" data-in-dir-field>
|
||||||
<div class="modal-header">
|
<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>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
@@ -158,6 +238,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# New directory modal #}
|
||||||
|
<div class="modal fade" id="newDirModal" tabindex="-1" aria-labelledby="newDirModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/admin/theme-dir-create-in">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|||||||