v2.5.1: Admin theme refactor, Navigation plugin, user roles, guide restructure
- Reorganize admin into admin/theme/default/ (views + assets) - Rename GuideNav to Navigation plugin (essential, protected) - Plugin assets support (SCSS/CSS) loaded after theme CSS - User roles: Admin, Content Manager, BI Manager, Site Admin - Role-based access control (RBAC) for admin routes and sidebar - Guide restructure: sub-topics in separate folders with sidebar nav - Dynamic breadcrumb for homepage and subdirectories - Fix theme path traversal (../../ -> ../) in admin.php - Fix CodeMirror mode load order (xml -> css -> js -> htmlmixed -> php) - Fix editor-toolbar.js null checks for plugin edit pages - Layout select from theme.json with live frontmatter update - Footer sticky at bottom of viewport (min-height: 100vh) - Breadcrumb color fix (var(--nav-font) -> var(--header-bg)) - Remove language switcher from guide pages - Update README.md and README.en.md - Bump version to 2.5.1
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
class Navigation
|
||||
{
|
||||
private array $config;
|
||||
private ?CMSAPI $api = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = [
|
||||
'title' => 'Navigatie',
|
||||
'viewable' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function setAPI(CMSAPI $api): void
|
||||
{
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to this plugin's CSS file (relative to web root).
|
||||
*/
|
||||
public function getCssUrl(): string
|
||||
{
|
||||
return '/plugins/Navigation/assets/css/navigation.css';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sidebar navigation.
|
||||
* Detects whether we're on a guide page or content page and builds nav accordingly.
|
||||
*/
|
||||
public function getSidebarContent(): string
|
||||
{
|
||||
$isGuide = isset($_GET['guide']) || ($_GET['page'] ?? '') === 'guide';
|
||||
$isAdminGuide = isset($_GET['route']) && $_GET['route'] === 'guide';
|
||||
$isAdmin = isset($_GET['route']);
|
||||
$lang = $_GET['lang'] ?? ($this->api ? $this->api->getCurrentLanguage() : 'nl');
|
||||
$currentPage = $_GET['page'] ?? '';
|
||||
|
||||
if ($isGuide || $isAdminGuide) {
|
||||
return $this->buildGuideNav($lang, $currentPage, $isAdminGuide);
|
||||
}
|
||||
|
||||
// Content navigation
|
||||
if ($isAdmin) {
|
||||
return '';
|
||||
}
|
||||
return $this->buildContentNav($lang, $currentPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build navigation for the guide section.
|
||||
*/
|
||||
private function buildGuideNav(string $lang, string $currentPage, bool $isAdmin): string
|
||||
{
|
||||
$guideRoot = dirname(__DIR__, 2) . '/guide/' . $lang;
|
||||
if (!is_dir($guideRoot)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Fix: /nl/guide sets page='guide', treat as empty
|
||||
if ($currentPage === 'guide') {
|
||||
$currentPage = '';
|
||||
}
|
||||
|
||||
return $this->buildNav($guideRoot, '', $currentPage, $isAdmin, 'guide', $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build navigation for the content section.
|
||||
*/
|
||||
private function buildContentNav(string $lang, string $currentPage): string
|
||||
{
|
||||
$contentRoot = dirname(__DIR__, 2) . '/content';
|
||||
if (!is_dir($contentRoot)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize current page: remove extension
|
||||
$currentPage = preg_replace('/\.(md|php|html)$/', '', $currentPage);
|
||||
|
||||
return $this->buildNav($contentRoot, '', $currentPage, false, 'content', $lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build navigation from a directory structure.
|
||||
*
|
||||
* @param string $dir Current directory
|
||||
* @param string $relPath Relative path from root
|
||||
* @param string $currentPage Current page path
|
||||
* @param bool $isAdmin Whether this is admin context
|
||||
* @param string $mode 'guide' or 'content'
|
||||
* @param string $lang Current language
|
||||
* @param int $depth Nesting depth (0 = top level)
|
||||
*/
|
||||
private function buildNav(string $dir, string $relPath, string $currentPage, bool $isAdmin, string $mode, string $lang, int $depth = 0): string
|
||||
{
|
||||
$items = [];
|
||||
$entries = scandir($dir);
|
||||
// Sort alphabetically (case-insensitive)
|
||||
natcasesort($entries);
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || $entry === 'assets' || $entry === 'index.md') {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden files/folders (starting with -)
|
||||
if ($entry[0] === '-' || $entry[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
// Skip non-content files in content mode
|
||||
if ($mode === 'content' && is_file($dir . '/' . $entry)) {
|
||||
$ext = pathinfo($entry, PATHINFO_EXTENSION);
|
||||
if (!in_array($ext, ['md', 'php', 'html'])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$fullPath = $dir . '/' . $entry;
|
||||
$itemRelPath = $relPath ? $relPath . '/' . $entry : $entry;
|
||||
|
||||
if (is_dir($fullPath)) {
|
||||
$parentMd = $dir . '/' . $entry . '.md';
|
||||
$title = file_exists($parentMd) ? $this->getTitleFromFile($parentMd) : $this->formatTitle($entry);
|
||||
|
||||
$pageRef = $relPath ? $relPath . '/' . $entry : $entry;
|
||||
$url = $this->buildUrl($pageRef, $lang, $isAdmin, $mode);
|
||||
$children = $this->buildNav($fullPath, $itemRelPath, $currentPage, $isAdmin, $mode, $lang, $depth + 1);
|
||||
|
||||
// Only show directories that have children or a parent .md
|
||||
if (empty($children) && !file_exists($parentMd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'title' => $title,
|
||||
'url' => $url,
|
||||
'active' => $this->isActive($pageRef, $currentPage, $mode),
|
||||
'children' => $children,
|
||||
'has_children' => !empty($children),
|
||||
];
|
||||
} else {
|
||||
$ext = pathinfo($entry, PATHINFO_EXTENSION);
|
||||
if (!in_array($ext, ['md', 'php', 'html'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = pathinfo($entry, PATHINFO_FILENAME);
|
||||
// Skip language-prefixed files (e.g. nl.pagina.md)
|
||||
$langCodes = $this->getLanguageCodes();
|
||||
foreach ($langCodes as $lc) {
|
||||
if (strpos($slug, $lc . '.') === 0) {
|
||||
$slug = substr($slug, strlen($lc) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Skip if this is a parent .md that has a matching directory
|
||||
if (is_dir($dir . '/' . $slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageRef = $relPath ? $relPath . '/' . $slug : $slug;
|
||||
$url = $this->buildUrl($pageRef, $lang, $isAdmin, $mode);
|
||||
|
||||
$items[] = [
|
||||
'title' => $this->getTitleFromFile($fullPath),
|
||||
'url' => $url,
|
||||
'active' => $this->isActive($pageRef, $currentPage, $mode),
|
||||
'children' => '',
|
||||
'has_children' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderNav($items, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL for a page.
|
||||
*/
|
||||
private function buildUrl(string $pageRef, string $lang, bool $isAdmin, string $mode): string
|
||||
{
|
||||
if ($mode === 'guide') {
|
||||
if ($isAdmin) {
|
||||
return '/admin/guide?page=' . urlencode($pageRef) . '&lang=' . urlencode($lang);
|
||||
}
|
||||
return '/' . urlencode($lang) . '/guide?page=' . urlencode($pageRef);
|
||||
}
|
||||
// Content mode
|
||||
return '/' . urlencode($lang) . '/' . urlencode($pageRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a nav item is the current page.
|
||||
*/
|
||||
private function isActive(string $pageRef, string $currentPage, string $mode): bool
|
||||
{
|
||||
if ($mode === 'guide' && $currentPage === 'guide') {
|
||||
$currentPage = '';
|
||||
}
|
||||
$currentPage = preg_replace('/\.(md|php|html)$/', '', $currentPage);
|
||||
return $pageRef === $currentPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available language codes.
|
||||
*/
|
||||
private function getLanguageCodes(): array
|
||||
{
|
||||
return ['nl', 'en', 'de', 'fr'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a slug into a readable title.
|
||||
*/
|
||||
private function formatTitle(string $slug): string
|
||||
{
|
||||
$title = str_replace('-', ' ', $slug);
|
||||
$title = ucfirst($title);
|
||||
return $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the H1 title from a markdown/PHP/HTML file.
|
||||
* Falls back to formatTitle() if no H1 is found.
|
||||
*/
|
||||
private function getTitleFromFile(string $filePath): string
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
// Match first H1: # Title
|
||||
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
$slug = pathinfo($filePath, PATHINFO_FILENAME);
|
||||
return $this->formatTitle($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the navigation items as HTML.
|
||||
*
|
||||
* @param array $items Navigation items
|
||||
* @param int $depth Nesting depth (0 = top level)
|
||||
*/
|
||||
private function renderNav(array $items, int $depth = 0): string
|
||||
{
|
||||
if (empty($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$subClass = $depth > 0 ? ' nav-sub' : '';
|
||||
$html = '<ul class="list-unstyled nav-plugin' . $subClass . '">';
|
||||
foreach ($items as $item) {
|
||||
$activeClass = $item['active'] ? ' active' : '';
|
||||
$parentClass = $item['has_children'] ? ' nav-parent' : '';
|
||||
$html .= '<li class="nav-plugin-item">';
|
||||
$html .= '<a href="' . htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') . '" class="nav-plugin-link' . $activeClass . $parentClass . '">';
|
||||
if ($item['has_children']) {
|
||||
$html .= '<i class="bi bi-folder-fill nav-plugin-icon"></i> ';
|
||||
} else {
|
||||
$html .= '<i class="bi bi-file-earmark nav-plugin-icon"></i> ';
|
||||
}
|
||||
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
|
||||
$html .= '</a>';
|
||||
|
||||
if ($item['has_children']) {
|
||||
$html .= $item['children'];
|
||||
}
|
||||
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Navigation plugin - compiled from assets/scss/navigation.scss */
|
||||
.nav-plugin {
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.nav-plugin-item {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.nav-plugin-link {
|
||||
display: block;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: #212529;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav-plugin-link:hover {
|
||||
background-color: #f8f9fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-plugin-link.active {
|
||||
background-color: #e7f1ff;
|
||||
border-left-color: #0a369d;
|
||||
color: #0a369d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-plugin-icon {
|
||||
font-size: 0.8em;
|
||||
margin-right: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.nav-parent {
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-parent .nav-plugin-icon {
|
||||
color: #0a369d;
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-sub {
|
||||
margin-left: 1.1rem;
|
||||
margin-top: 0.2rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 1px solid #dee2e6;
|
||||
}
|
||||
.nav-sub .nav-plugin-link {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
color: #495057;
|
||||
}
|
||||
.nav-sub .nav-plugin-link.active {
|
||||
color: #0a369d;
|
||||
}
|
||||
.nav-sub .nav-plugin-icon {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Navigation plugin styles
|
||||
// Compiled by ThemeManager (scssphp) into plugins/Navigation/assets/css/navigation.css
|
||||
|
||||
.nav-plugin {
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-plugin-item {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-plugin-link {
|
||||
display: block;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: #212529;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-plugin-link:hover {
|
||||
background-color: #f8f9fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-plugin-link.active {
|
||||
background-color: #e7f1ff;
|
||||
border-left-color: #0a369d;
|
||||
color: #0a369d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-plugin-icon {
|
||||
font-size: 0.8em;
|
||||
margin-right: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nav-parent {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-parent .nav-plugin-icon {
|
||||
color: #0a369d;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Sub-navigation (nested items)
|
||||
.nav-sub {
|
||||
margin-left: 1.1rem;
|
||||
margin-top: 0.2rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.nav-sub .nav-plugin-link {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.nav-sub .nav-plugin-link.active {
|
||||
color: #0a369d;
|
||||
}
|
||||
|
||||
.nav-sub .nav-plugin-icon {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Navigation",
|
||||
"version": "1.0.0",
|
||||
"author": "CodePress",
|
||||
"description": "Essentiële navigatie plugin voor handleidingen en content"
|
||||
}
|
||||
Reference in New Issue
Block a user