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:
2026-08-10 15:36:29 +02:00
parent 0961b23b8d
commit cd498c8c3a
209 changed files with 11033 additions and 9125 deletions
-339
View File
@@ -1,339 +0,0 @@
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
// GeoIP2 will be loaded conditionally when available
class MQTTTracker
{
private ?CMSAPI $api = null;
private array $config;
private string $sessionId;
private $geoipReader = null;
public function __construct()
{
$this->loadConfig();
$this->sessionId = $this->generateSessionId();
$this->initializeGeoIP();
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
// Track page visit after API is available
$this->trackPageVisit();
}
private function loadConfig(): void
{
$configFile = __DIR__ . '/config.json';
$this->config = [
'enabled' => true,
'viewable' => false,
'broker_host' => getenv('MQTT_BROKER_HOST') ?: 'localhost',
'broker_port' => getenv('MQTT_BROKER_PORT') ?: 1883,
'client_id' => 'codepress_cms',
'username' => getenv('MQTT_USERNAME') ?: '',
'password' => getenv('MQTT_PASSWORD') ?: '',
'topic_prefix' => 'codepress',
'track_visitors' => true,
'track_pages' => true,
'track_performance' => true,
'track_user_flows' => true,
'session_timeout' => 1800,
'geoip_database_path' => __DIR__ . '/GeoLite2-Country.mmdb'
];
if (file_exists($configFile)) {
$jsonConfig = json_decode(file_get_contents($configFile), true);
// Only merge non-sensitive keys from config file
$sensitiveKeys = ['password', 'username'];
foreach ($jsonConfig as $key => $value) {
if (!in_array($key, $sensitiveKeys, true)) {
$this->config[$key] = $value;
}
}
}
}
private function initializeGeoIP(): void
{
$geoipPath = $this->config['geoip_database_path'] ?? __DIR__ . '/GeoLite2-Country.mmdb';
// For now, disable GeoIP2 until properly configured
$this->logMessage('info', 'GeoIP2 temporarily disabled - will be enabled when database is available');
$this->geoipReader = null;
}
private function generateSessionId(): string
{
if (isset($_COOKIE['cms_session_id'])) {
return $_COOKIE['cms_session_id'];
}
$sessionId = uniqid('cms_', true);
setcookie('cms_session_id', $sessionId, [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
return $sessionId;
}
private function trackPageVisit(): void
{
if (!$this->config['enabled'] || !$this->config['track_pages']) {
return;
}
// Track user flow before updating current page
$this->trackUserFlow();
// Format URL nicely: ?page=foo/bar -> /page/foo/bar
// Sanitize REQUEST_URI to prevent CRLF injection
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
// URL-decode first to catch encoded CRLF characters
$decodedUri = urldecode($requestUri);
// Remove CRLF and null characters
$sanitizedUri = preg_replace('/[\r\n\0]/', '', $decodedUri);
// Re-encode for safe use in cookie
$pageUrl = htmlspecialchars($sanitizedUri, ENT_QUOTES, 'UTF-8');
if (isset($_GET['page'])) {
// Sanitize page parameter to prevent CRLF injection
$pageParam = preg_replace('/[\r\n\0]/', '', $_GET['page']);
$pageUrl = '/page/' . urlencode($pageParam);
// Append other relevant params if needed, e.g., language
if (isset($_GET['lang'])) {
// Sanitize lang parameter to prevent CRLF injection
$langParam = preg_replace('/[\r\n\0]/', '', $_GET['lang']);
$pageUrl .= '?lang=' . urlencode($langParam);
}
}
$clientIp = $this->getClientIp();
$geoData = $this->getGeoData($clientIp);
$pageData = [
'timestamp' => date('c'),
'session_id' => $this->sessionId,
'page_url' => $pageUrl,
'page_title' => $this->api ? $this->api->getCurrentPageTitle() : '',
'referrer' => $_SERVER['HTTP_REFERER'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'ip_address' => $clientIp,
'country' => $geoData['country'],
'country_code' => $geoData['country_code'],
'city' => $geoData['city'],
'language' => $this->api ? $this->api->getCurrentLanguage() : 'nl',
'layout' => $this->api ? $this->getPageLayout() : 'unknown',
'device_type' => $this->getDeviceType(),
'browser' => $this->getBrowser(),
'os' => $this->getOS()
];
// Update tracking cookies
setcookie('cms_previous_page', $pageUrl, [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
setcookie('cms_page_timestamp', (string) time(), [
'expires' => time() + $this->config['session_timeout'],
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
$this->publishMessage('page_visit', $pageData);
}
private function getPageLayout(): string
{
if (!$this->api) return 'unknown';
$page = $this->api->getCurrentPage();
return $page['layout'] ?? 'sidebar-content';
}
private function trackUserFlow(): void
{
if (!($this->config['track_user_flows'] ?? true)) {
return;
}
$previousPage = $_COOKIE['cms_previous_page'] ?? null;
$currentPage = $this->getCurrentPageUrl();
$previousTimestamp = $_COOKIE['cms_page_timestamp'] ?? time();
if ($previousPage && $previousPage !== $currentPage) {
$flowData = [
'timestamp' => date('c'),
'session_id' => $this->sessionId,
'from_page' => $previousPage,
'to_page' => $currentPage,
'flow_duration' => time() - $previousTimestamp,
'ip_address' => $this->getClientIp()
];
$this->publishMessage('user_flow', $flowData);
}
}
private function getCurrentPageUrl(): string
{
$pageUrl = $_SERVER['REQUEST_URI'] ?? '';
if (isset($_GET['page'])) {
$pageUrl = '/page/' . $_GET['page'];
if (isset($_GET['lang'])) {
$pageUrl .= '?lang=' . $_GET['lang'];
}
}
return $pageUrl;
}
private function getGeoData(string $ip): array
{
// Simplified geolocation - will be enhanced later
return [
'country' => 'Unknown',
'country_code' => 'XX',
'city' => 'Unknown'
];
}
private function getDeviceType(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Mobile|Android|iPhone|iPad|iPod/', $userAgent)) {
return preg_match('/iPad/', $userAgent) ? 'tablet' : 'mobile';
}
return 'desktop';
}
private function getBrowser(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Chrome/', $userAgent)) return 'Chrome';
if (preg_match('/Firefox/', $userAgent)) return 'Firefox';
if (preg_match('/Safari/', $userAgent)) return 'Safari';
if (preg_match('/Edge/', $userAgent)) return 'Edge';
if (preg_match('/Opera/', $userAgent)) return 'Opera';
return 'Unknown';
}
private function getOS(): string
{
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (preg_match('/Windows/', $userAgent)) return 'Windows';
if (preg_match('/Mac/', $userAgent)) return 'macOS';
if (preg_match('/Linux/', $userAgent)) return 'Linux';
if (preg_match('/Android/', $userAgent)) return 'Android';
if (preg_match('/iOS|iPhone|iPad/', $userAgent)) return 'iOS';
return 'Unknown';
}
private function getClientIp(): string
{
// Only trust REMOTE_ADDR by default - proxy headers can be spoofed
// Configure trusted_proxies in config to enable proxy header support
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$trustedProxies = $this->config['trusted_proxies'] ?? [];
if (!empty($trustedProxies) && in_array($remoteAddr, $trustedProxies)) {
// Only trust proxy headers when request comes from a known proxy
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
}
return $remoteAddr;
}
private function publishMessage(string $topic, array $data): void
{
if (!class_exists('PhpMqtt\Client\MqttClient')) {
$this->logMessage('error', 'MQTT client library not installed. Run: composer require php-mqtt/client');
return;
}
try {
$server = new \PhpMqtt\Client\MqttClient(
$this->config['broker_host'],
$this->config['broker_port'],
$this->config['client_id']
);
$connectionSettings = new \PhpMqtt\Client\ConnectionSettings();
if (!empty($this->config['username'])) {
$connectionSettings->setUsername($this->config['username'])
->setPassword($this->config['password']);
}
$server->connect($connectionSettings, true);
// Topic format: prefix/action
$topic = $this->config['topic_prefix'] . '/' . $topic;
$payload = json_encode($data);
$server->publish($topic, $payload, 0);
$server->disconnect();
$this->logMessage('published', $topic . ' - ' . $payload);
} catch (Exception $e) {
$this->logMessage('error', 'MQTT publish failed: ' . $e->getMessage());
}
}
private function logMessage(string $topic, string $payload): void
{
$logFile = __DIR__ . '/mqtt_tracker.log';
$logEntry = date('Y-m-d H:i:s') . " [{$topic}] {$payload}\n";
file_put_contents($logFile, $logEntry, FILE_APPEND | LOCK_EX);
}
public function getSidebarContent(): string
{
// MQTT Tracker is een functionele plugin zonder UI
return '';
}
public function getConfig(): array
{
return $this->config;
}
public function updateConfig(array $newConfig): void
{
$this->config = array_merge($this->config, $newConfig);
$configFile = __DIR__ . '/config.json';
file_put_contents($configFile, json_encode($this->config, JSON_PRETTY_PRINT));
}
}
-83
View File
@@ -1,83 +0,0 @@
# MQTT Tracker Plugin
Deze plugin tracked pagina bezoeken en gebruikersinteracties via MQTT voor Business Intelligence en statistieken.
## Functies
- **Real-time tracking**: Track elke pagina bezoeker
- **Session management**: Unieke sessies per gebruiker
- **MQTT integratie**: Verstuurt data naar MQTT broker
- **BI data**: Geschikt voor analyse en dashboards
- **Privacy aware**: IP tracking en user agent data
## Installatie
1. Kopieer de `MQTTTracker` map naar `plugins/`
2. Configureer de MQTT broker in `config.json`
3. De plugin wordt automatisch geladen
## Configuratie
```json
{
"enabled": true,
"broker_host": "localhost",
"broker_port": 1883,
"client_id": "codepress_cms",
"username": "",
"password": "",
"topic_prefix": "codepress",
"track_visitors": true,
"track_pages": true,
"track_performance": true,
"session_timeout": 1800
}
```
## MQTT Topics
De plugin publiceert naar de volgende topics:
- `codepress/page_visit` - Elke pagina bezoeker
- `codepress/session_start` - Nieuwe sessie start
- `codepress/custom_event` - Custom interacties
## Data Formaat
### Page Visit
```json
{
"timestamp": "2025-11-26T15:30:00+00:00",
"session_id": "cms_1234567890abcdef",
"page_url": "?page=demo/sidebar-content&lang=nl",
"page_title": "Sidebar-Content Layout",
"referrer": "https://google.com",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.100",
"language": "nl",
"layout": "sidebar-content"
}
```
## BI Integration
De data kan worden gebruikt voor:
- **Google Data Studio**: Real-time dashboards
- **Grafana**: Visualisatie en monitoring
- **Power BI**: Business analytics
- **Custom dashboards**: Eigen analytics tools
## Privacy
- Sessies timeout na 30 minuten
- IP addresses worden geanonimiseerd
- Geen persoonlijke data opslag
- GDPR compliant
## Development
De plugin gebruikt een simpele logging methode als fallback wanneer MQTT niet beschikbaar is. Voor productie gebruik wordt een echte MQTT client library aanbevolen.
## Log File
Tracking data wordt gelogd in `plugins/MQTTTracker/mqtt_tracker.log` voor debugging en fallback.
-16
View File
@@ -1,16 +0,0 @@
{
"enabled": true,
"viewable": false,
"broker_host": "mqtt.lan.noorlander.info",
"broker_port": "1883",
"client_id": "codepress_cms",
"username": "",
"password": "",
"topic_prefix": "codepress",
"track_visitors": true,
"track_pages": true,
"track_performance": true,
"track_user_flows": true,
"session_timeout": "1800",
"geoip_database_path": "\/plugins\/MQTTTracker\/GeoLite2-Country.mmdb"
}
+291
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Navigation",
"version": "1.0.0",
"author": "CodePress",
"description": "Essentiële navigatie plugin voor handleidingen en content"
}