Refactor: Replace sidebar with horizontal navigation bar

- Remove sidebar and toggle functionality
- Add Bootstrap navbar with dropdown menus
- Move navigation to top between header and content
- Update menu rendering for Bootstrap dropdowns
- Clean up unused files (header.mustache, sidebar.mustache, sidebar.js)
- Add guide link with book icon in footer
- Simplify layout structure
- Remove duplicate code and fix syntax errors
- Add .gitignore for node_modules and other temp files
This commit is contained in:
2025-11-21 14:23:41 +01:00
parent 0f1c7234b8
commit a86809c243
87 changed files with 8369 additions and 1353 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,246 @@
// SCSS variables
$primary: #0d6efd;
$secondary: #6c757d;
$success: #198754;
$info: #0dcaf0;
$warning: #ffc107;
$danger: #dc3545;
$light: #f8f9fa;
$dark: #212529;
// SCSS mixins
@mixin transition($properties...) {
transition: $properties;
}
// Custom styles
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100vh - 70px);
}
.content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 250px;
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
overflow-y: auto;
flex-shrink: 0;
@include transition(transform 0.3s ease);
position: fixed;
top: 70px;
left: 0;
height: calc(100vh - 140px);
z-index: 999;
transform: translateX(0);
}
.sidebar.collapsed {
transform: translateX(-250px);
}
.sidebar-toggle {
position: absolute;
top: 15px;
right: 15px;
z-index: 1001;
background: none;
border: none;
cursor: pointer;
@include transition(all 0.3s ease);
font-size: 20px;
color: #6c757d;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
color: #0d6efd;
background-color: #f8f9fa;
}
}
.sidebar-toggle-outer {
position: relative;
z-index: 1001;
background-color: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
margin-right: 10px;
color: white !important;
&:hover {
background-color: rgba(255, 255, 255, 0.3);
color: white !important;
}
}
.sidebar.collapsed .sidebar-toggle-inner {
right: auto;
left: 15px;
}
.sidebar.collapsed ~ .main-content {
margin-left: 0;
}
.sidebar.collapsed .sidebar-toggle-outer {
display: block !important;
}
.sidebar:not(.collapsed) .sidebar-toggle-outer {
display: none !important;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 20px;
@include transition(margin-left 0.3s ease);
margin-left: 250px;
}
.folder-toggle {
font-weight: bold;
color: #212529 !important;
cursor: pointer;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: #f8f9fa !important;
margin: 0.125rem 0;
@include transition(background-color 0.2s ease);
&:hover {
background-color: #e9ecef !important;
}
&[aria-expanded=true] {
background-color: #dee2e6 !important;
color: #212529 !important;
font-weight: 600;
}
.arrow {
margin-right: 8px;
@include transition(transform 0.2s);
font-size: 0.8em;
}
&[aria-expanded=true] .arrow {
transform: rotate(90deg);
}
}
.page-link {
color: #495057 !important;
font-weight: 500;
padding: 0.5rem 0.75rem;
padding-left: 2rem;
background-color: #ffffff !important;
margin: 0.125rem 0;
@include transition(background-color 0.2s ease);
&:hover {
color: #212529 !important;
background-color: #f8f9fa !important;
}
&.active {
background-color: #0d6efd !important;
color: #212529 !important;
font-weight: 600;
}
}
.file-info {
font-size: 0.9rem;
color: #6c757d;
}
.site-info a {
color: #0d6efd;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.auto-link {
color: #0d6efd;
text-decoration: none;
border-bottom: 2px dashed #0d6efd;
font-weight: 500;
@include transition(all 0.2s ease);
&:hover {
color: #0a58ca;
text-decoration: none;
border-bottom-style: solid;
border-bottom-color: #0a58ca;
}
}
.search-form {
max-width: 300px;
}
.card-title a {
text-decoration: none;
color: inherit;
&:hover {
text-decoration: underline;
}
}
.folder-disabled {
font-weight: 500;
color: #6c757d !important;
cursor: not-allowed;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: #f8f9fa !important;
margin: 0.125rem 0;
opacity: 0.7;
.arrow {
margin-right: 8px;
font-size: 0.8em;
opacity: 0.6;
}
}
@media (max-width: 768px) {
.sidebar {
width: 200px;
}
}

View File

@@ -1,11 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- < -->
<path d="M8 8 L3 16 L8 24" stroke="#ffffff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- / -->
<path d="M12 24 L18 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
<!-- .. -->
<circle cx="22" cy="20" r="2" fill="#ffffff"/>
<circle cx="28" cy="20" r="2" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 442 B

View File

@@ -1,11 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- < -->
<path d="M8 8 L3 16 L8 24" stroke="#ffffff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- / -->
<path d="M12 24 L18 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
<!-- .. -->
<circle cx="22" cy="20" r="2" fill="#ffffff"/>
<circle cx="28" cy="20" r="2" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 442 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,8 @@
<?php
return [
'site_title' => 'CodePress',
'site_description' => 'A simple PHP CMS',
'base_url' => '/',
'content_dir' => __DIR__ . '/../../content',
'templates_dir' => __DIR__ . '/../templates',
'cache_dir' => __DIR__ . '/../../cache',
'default_page' => 'home',
'error_404' => '404',
'markdown_enabled' => true,
'php_enabled' => true,
'bootstrap_version' => '5.3.0',
'jquery_version' => '3.7.1'
'default_page' => 'home'
];

View File

@@ -2,6 +2,52 @@
require_once 'config.php';
// Simple template rendering without Mustache for now
class SimpleTemplate {
public static function render($template, $data) {
// Handle conditional blocks first
foreach ($data as $key => $value) {
if (is_array($value) || (is_string($value) && !empty($value))) {
// Handle {{#key}}...{{/key}} blocks
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$replacement = $matches[1];
$template = preg_replace($pattern, $replacement, $template);
}
// Handle {{^key}}...{{/key}} blocks (negative condition)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
} else {
// Handle empty blocks
$pattern = '/{{#' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
// Handle {{^key}}...{{/key}} blocks (show when empty)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match_all($pattern, $template, $matches)) {
foreach ($matches[1] as $match) {
$template = preg_replace('/{{\^' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s', $match, $template, 1);
}
}
}
}
// Handle variable replacements
foreach ($data as $key => $value) {
// Handle triple braces for unescaped HTML content
if (strpos($template, '{{{' . $key . '}}}') !== false) {
$template = str_replace('{{{' . $key . '}}}', $value, $template);
}
// Handle double braces for escaped content
elseif (strpos($template, '{{' . $key . '}}') !== false) {
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
}
}
return $template;
}
}
$config = include 'config.php';
class CodePressCMS {
@@ -104,6 +150,16 @@ class CodePressCMS {
return $this->getSearchResults();
}
// Check if guide is requested
if (isset($_GET['guide'])) {
return $this->getGuidePage();
}
// Check if content directory is empty
if ($this->isContentDirEmpty()) {
return $this->getGuidePage();
}
$page = $_GET['page'] ?? $this->config['default_page'];
$page = preg_replace('/\.[^.]+$/', '', $page);
@@ -348,6 +404,46 @@ private function autoLinkPageTitles($content) {
];
}
private function isContentDirEmpty() {
$contentDir = $this->config['content_dir'];
if (!is_dir($contentDir)) {
return true;
}
$files = scandir($contentDir);
$files = array_diff($files, ['.', '..']);
return empty($files);
}
private function getGuidePage() {
$lang = $this->detectLanguage();
$guideFile = __DIR__ . '/../../guide/' . $lang . '.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../../guide/en.md'; // Fallback to English
}
$content = file_get_contents($guideFile);
$result = $this->parseMarkdown($content);
// Set special title for guide
$result['title'] = 'Handleiding - CodePress CMS';
return $result;
}
private function detectLanguage() {
// Simple language detection based on browser Accept-Language header
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
if (strpos($acceptLanguage, 'nl') !== false) {
return 'nl';
}
return 'en'; // Default to English
}
private function getError404() {
return [
'title' => 'Page Not Found',
@@ -364,27 +460,59 @@ private function autoLinkPageTitles($content) {
$menu = $this->getMenu();
$breadcrumb = $this->getBreadcrumb();
$template = file_get_contents($this->config['templates_dir'] . '/layout.html');
$template = str_replace('{{site_title}}', $this->config['site_title'], $template);
$template = str_replace('{{page_title}}', $page['title'], $template);
$template = str_replace('{{content}}', $page['content'], $template);
$template = str_replace('{{search_query}}', isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '', $template);
$template = str_replace('{{breadcrumb}}', $breadcrumb, $template);
// Prepare template data
$templateData = [
'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']),
'content' => $page['content'],
'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '',
'menu' => $this->renderMenu($menu),
'breadcrumb' => $breadcrumb,
'default_page' => $this->config['default_page']
];
// File info for footer
$fileInfo = '';
if (isset($page['file_info'])) {
$fileInfo = '<i class="bi bi-file-text"></i> Created: ' . htmlspecialchars($page['file_info']['created']) .
' | Modified: ' . htmlspecialchars($page['file_info']['modified']);
$templateData['file_info'] = 'Created: ' . htmlspecialchars($page['file_info']['created']) .
' | Modified: ' . htmlspecialchars($page['file_info']['modified']);
} else {
$templateData['file_info'] = '';
}
$template = str_replace('{{file_info}}', $fileInfo, $template);
$menuHtml = $this->renderMenu($menu);
// Check if content exists for guide link
$hasContent = !$this->isContentDirEmpty();
$templateData['has_content'] = $hasContent;
$template = str_replace('{{menu}}', $menuHtml, $template);
// Don't show site title link on guide page
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
echo $template;
// Load partials manually
$hasContent = !$this->isContentDirEmpty() && !isset($_GET['guide']);
$headerContent = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
if (!$hasContent) {
// Remove the link from header when no content
$headerContent = preg_replace('/<a href="[^"]*" class="site-title-link">\s*<h1[^>]*>(.*?)<\/h1>\s*<\/a>/', '<h1 class="h3 mb-0">$1</h1>', $headerContent);
}
$footerContent = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
if (!$hasContent) {
// Remove guide link from footer when no content
$footerContent = preg_replace('/<span class="file-details">\s*\|\s*<a href="\?guide"[^>]*>Handleiding<\/a><\/span>/', '', $footerContent);
}
$partials = [
'navigation' => file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache'),
'footer' => file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache')
];
// Replace partials in template
$template = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
$template = str_replace('{{>navigation}}', $partials['navigation'], $template);
$template = str_replace('{{>footer}}', $partials['footer'], $template);
// Render template with data
echo SimpleTemplate::render($template, $templateData);
}
private function getBreadcrumb() {
@@ -423,33 +551,27 @@ private function autoLinkPageTitles($content) {
foreach ($items as $item) {
if ($item['type'] === 'folder') {
$hasChildren = !empty($item['children']);
$html .= '<li class="nav-item">';
if ($hasChildren) {
$folderId = 'folder-' . str_replace('/', '-', $item['path']);
// Check if this folder contains the active page
$containsActive = $this->folderContainsActivePage($item['children']);
$ariaExpanded = $containsActive ? 'true' : 'false';
$collapseClass = $containsActive ? 'collapse show' : 'collapse';
$html .= '<span class="nav-link folder-toggle" data-bs-toggle="collapse" data-bs-target="#' . $folderId . '" aria-expanded="' . $ariaExpanded . '">';
$html .= '<i class="arrow bi bi-chevron-right"></i> ' . htmlspecialchars($item['title']);
$html .= '</span>';
$html .= '<ul class="nav flex-column ms-2 ' . $collapseClass . '" id="' . $folderId . '">';
$isExpanded = $this->folderContainsActivePage($item['children']);
$html .= '<li class="nav-item dropdown">';
$html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
$html .= htmlspecialchars($item['title']);
$html .= '</a>';
$html .= '<ul class="dropdown-menu" aria-labelledby="' . $folderId . '">';
$html .= $this->renderMenu($item['children'], $level + 1);
$html .= '</ul>';
$html .= '</li>';
} else {
$html .= '<span class="nav-link folder-disabled" disabled>';
$html .= '<i class="arrow bi bi-chevron-right"></i> ' . htmlspecialchars($item['title']);
$html .= '</span>';
$html .= '<li class="nav-item">';
$html .= '<span class="nav-link text-muted">' . htmlspecialchars($item['title']) . '</span>';
$html .= '</li>';
}
$html .= '</li>';
} else {
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link page-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
$html .= '<a class="nav-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
$html .= '</li>';
}
}
@@ -470,7 +592,4 @@ private function autoLinkPageTitles($content) {
}
return false;
}
}
$cms = new CodePressCMS($config);
$cms->render();
}

View File

@@ -0,0 +1,615 @@
<?php
require_once 'config.php';
// Simple template rendering without Mustache for now
class SimpleTemplate {
public static function render($template, $data) {
// Handle conditional blocks first
foreach ($data as $key => $value) {
if (is_array($value) || (is_string($value) && !empty($value))) {
// Handle {{#key}}...{{/key}} blocks
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$replacement = $matches[1];
$template = preg_replace($pattern, $replacement, $template);
}
// Handle {{^key}}...{{/key}} blocks (negative condition)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
} else {
// Handle empty blocks
$pattern = '/{{#' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
// Handle {{^key}}...{{/key}} blocks (show when empty)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match_all($pattern, $template, $matches)) {
foreach ($matches[1] as $match) {
$template = preg_replace('/{{\^' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s', $match, $template, 1);
}
}
}
}
// Handle variable replacements
foreach ($data as $key => $value) {
// Handle triple braces for unescaped HTML content
if (strpos($template, '{{{' . $key . '}}}') !== false) {
$template = str_replace('{{{' . $key . '}}}', $value, $template);
}
// Handle double braces for escaped content
elseif (strpos($template, '{{' . $key . '}}') !== false) {
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
}
}
return $template;
}
}
$config = include 'config.php';
class CodePressCMS {
private $config;
private $menu = [];
private $searchResults = [];
public function __construct($config) {
$this->config = $config;
$this->buildMenu();
if (isset($_GET['search'])) {
$this->performSearch($_GET['search']);
}
}
private function buildMenu() {
$this->menu = $this->scanDirectory($this->config['content_dir'], '');
}
private function scanDirectory($dir, $prefix) {
if (!is_dir($dir)) return [];
$items = scandir($dir);
sort($items);
$result = [];
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$result[] = [
'type' => 'folder',
'title' => ucfirst($item),
'path' => $relativePath,
'children' => $this->scanDirectory($path, $relativePath)
];
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = ucfirst(pathinfo($item, PATHINFO_FILENAME));
$result[] = [
'type' => 'file',
'title' => $title,
'path' => $relativePath,
'url' => '?page=' . $relativePath
];
}
}
return $result;
}
private function performSearch($query) {
$this->searchResults = [];
$this->searchInDirectory($this->config['content_dir'], '', $query);
}
private function searchInDirectory($dir, $prefix, $query) {
if (!is_dir($dir)) return;
$items = scandir($dir);
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$this->searchInDirectory($path, $relativePath, $query);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$content = file_get_contents($path);
if (stripos($content, $query) !== false || stripos($item, $query) !== false) {
$title = ucfirst(pathinfo($item, PATHINFO_FILENAME));
$this->searchResults[] = [
'title' => $title,
'path' => $relativePath,
'url' => '?page=' . $relativePath,
'snippet' => $this->createSnippet($content, $query)
];
}
}
}
}
private function createSnippet($content, $query) {
$content = strip_tags($content);
$pos = stripos($content, $query);
if ($pos === false) return substr($content, 0, 100) . '...';
$start = max(0, $pos - 50);
$snippet = substr($content, $start, 150);
return '...' . $snippet . '...';
}
public function getPage() {
if (isset($_GET['search'])) {
return $this->getSearchResults();
}
// Check if guide is requested
if (isset($_GET['guide'])) {
return $this->getGuidePage();
}
// Check if content directory is empty
if ($this->isContentDirEmpty()) {
return $this->getGuidePage();
}
$page = $_GET['page'] ?? $this->config['default_page'];
$page = preg_replace('/\.[^.]+$/', '', $page);
$filePath = $this->config['content_dir'] . '/' . $page;
$actualFilePath = null;
if (file_exists($filePath . '.md')) {
$actualFilePath = $filePath . '.md';
$result = $this->parseMarkdown(file_get_contents($actualFilePath));
} elseif (file_exists($filePath . '.php')) {
$actualFilePath = $filePath . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($filePath . '.html')) {
$actualFilePath = $filePath . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
} elseif (file_exists($filePath)) {
$actualFilePath = $filePath;
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($extension === 'md') {
$result = $this->parseMarkdown(file_get_contents($actualFilePath));
} elseif ($extension === 'php') {
$result = $this->parsePHP($actualFilePath);
} elseif ($extension === 'html') {
$result = $this->parseHTML(file_get_contents($actualFilePath));
}
}
if (isset($result) && $actualFilePath) {
$result['file_info'] = $this->getFileInfo($actualFilePath);
return $result;
}
return $this->getError404();
}
private function getFileInfo($filePath) {
if (!file_exists($filePath)) {
return null;
}
$stats = stat($filePath);
$created = date('d-m-Y H:i', $stats['ctime']);
$modified = date('d-m-Y H:i', $stats['mtime']);
return [
'created' => $created,
'modified' => $modified,
'size' => $this->formatFileSize($stats['size'])
];
}
private function formatFileSize($bytes) {
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
private function getSearchResults() {
$query = $_GET['search'];
$content = '<h2>Search Results for: "' . htmlspecialchars($query) . '"</h2>';
if (empty($this->searchResults)) {
$content .= '<p>No results found.</p>';
} else {
$content .= '<p>Found ' . count($this->searchResults) . ' results:</p>';
foreach ($this->searchResults as $result) {
$content .= '<div class="card mb-3">';
$content .= '<div class="card-body">';
$content .= '<h5 class="card-title"><a href="' . htmlspecialchars($result['url']) . '">' . htmlspecialchars($result['title']) . '</a></h5>';
$content .= '<p class="card-text text-muted">' . htmlspecialchars($result['path']) . '</p>';
$content .= '<p class="card-text">' . htmlspecialchars($result['snippet']) . '</p>';
$content .= '</div></div>';
}
}
return [
'title' => 'Search Results',
'content' => $content
];
}
private function parseMarkdown($content) {
$lines = explode("\n", $content);
$title = '';
$body = '';
$inBody = false;
foreach ($lines as $line) {
if (!$inBody && preg_match('/^#\s+(.+)$/', $line, $matches)) {
$title = $matches[1];
$inBody = true;
} elseif ($inBody || trim($line) !== '') {
$body .= $line . "\n";
$inBody = true;
}
}
$body = preg_replace('/### (.+)/', '<h3>$1</h3>', $body);
$body = preg_replace('/## (.+)/', '<h2>$1</h2>', $body);
$body = preg_replace('/# (.+)/', '<h1>$1</h1>', $body);
$body = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $body);
$body = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $body);
// Auto-link page titles to existing content pages (before markdown link processing)
$body = $this->autoLinkPageTitles($body);
// Convert Markdown links to HTML links
$body = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $body);
// Convert relative internal links to CMS format
$body = preg_replace('/href="\/blog\/([^"]+)"/', 'href="?page=blog/$1"', $body);
$body = preg_replace('/href="\/([^"]+)"/', 'href="?page=$1"', $body);
$body = preg_replace('/\n\n/', '</p><p>', $body);
$body = '<p>' . $body . '</p>';
$body = preg_replace('/<p><\/p>/', '', $body);
$body = preg_replace('/<p>(<h[1-6]>)/', '$1', $body);
$body = preg_replace('/(<\/h[1-6]>)<\/p>/', '$1', $body);
return [
'title' => $title ?: 'Untitled',
'content' => $body
];
}
private function autoLinkPageTitles($content) {
// Get all available pages with their titles
$pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) {
// Create a pattern that matches the exact page title (case-insensitive)
// Use word boundaries to avoid partial matches
$pattern = '/\b' . preg_quote($pageTitle, '/') . '\b/i';
// Replace with link, but avoid linking inside existing links, headings, or markdown
$replacement = function($matches) use ($pageTitle, $pagePath) {
$text = $matches[0];
// Check if we're inside an existing link or markdown syntax
if (preg_match('/\[.*?\]\(.*?\)/', $text) ||
preg_match('/\[.*?\]:/', $text) ||
preg_match('/<a[^>]*>/', $text) ||
preg_match('/href=/', $text)) {
return $text; // Don't link existing links
}
return '<a href="?page=' . $pagePath . '" class="auto-link" title="Ga naar ' . htmlspecialchars($pageTitle) . '">' . $text . '</a>';
};
$content = preg_replace_callback($pattern, $replacement, $content);
}
return $content;
}
private function getAllPageTitles() {
$pages = [];
$this->scanForPageTitles($this->config['content_dir'], '', $pages);
return $pages;
}
private function scanForPageTitles($dir, $prefix, &$pages) {
if (!is_dir($dir)) return;
$items = scandir($dir);
sort($items);
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$this->scanForPageTitles($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = $this->extractPageTitle($path);
if ($title && !empty(trim($title))) {
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $title;
}
}
}
}
private function extractPageTitle($filePath) {
$content = file_get_contents($filePath);
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($extension === 'md') {
// Extract first H1 from Markdown
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
return trim($matches[1]);
}
} elseif ($extension === 'php') {
// Extract title from PHP file
if (preg_match('/\$title\s*=\s*["\']([^"\']+)["\']/', $content, $matches)) {
return trim($matches[1]);
}
} elseif ($extension === 'html') {
// Extract title from HTML file
if (preg_match('/<title>(.*?)<\/title>/i', $content, $matches)) {
return trim(strip_tags($matches[1]));
}
if (preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $matches)) {
return trim(strip_tags($matches[1]));
}
}
return null;
}
private function parsePHP($filePath) {
ob_start();
$title = 'Untitled';
include $filePath;
$content = ob_get_clean();
return [
'title' => $title,
'content' => $content
];
}
private function parseHTML($content) {
$title = 'Untitled';
if (preg_match('/<title>(.*?)<\/title>/i', $content, $matches)) {
$title = strip_tags($matches[1]);
} elseif (preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $matches)) {
$title = strip_tags($matches[1]);
}
return [
'title' => $title,
'content' => $content
];
}
private function isContentDirEmpty() {
$contentDir = $this->config['content_dir'];
if (!is_dir($contentDir)) {
return true;
}
$files = scandir($contentDir);
$files = array_diff($files, ['.', '..']);
return empty($files);
}
private function getGuidePage() {
$lang = $this->detectLanguage();
$guideFile = __DIR__ . '/../../guide/' . $lang . '.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../../guide/en.md'; // Fallback to English
}
$content = file_get_contents($guideFile);
$result = $this->parseMarkdown($content);
// Set special title for guide
$result['title'] = 'Handleiding - CodePress CMS';
return $result;
}
private function detectLanguage() {
// Simple language detection based on browser Accept-Language header
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
if (strpos($acceptLanguage, 'nl') !== false) {
return 'nl';
}
return 'en'; // Default to English
}
private function getError404() {
return [
'title' => 'Page Not Found',
'content' => '<h1>404 - Page Not Found</h1><p>The page you are looking for does not exist.</p>'
];
}
public function getMenu() {
return $this->menu;
}
public function render() {
$page = $this->getPage();
$menu = $this->getMenu();
$breadcrumb = $this->getBreadcrumb();
// Prepare template data
$templateData = [
'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']),
'content' => $page['content'],
'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '',
'menu' => $this->renderMenu($menu),
'breadcrumb' => $breadcrumb,
'default_page' => $this->config['default_page']
];
// File info for footer
if (isset($page['file_info'])) {
$templateData['file_info'] = 'Created: ' . htmlspecialchars($page['file_info']['created']) .
' | Modified: ' . htmlspecialchars($page['file_info']['modified']);
} else {
$templateData['file_info'] = '';
}
// Check if content exists for guide link
$hasContent = !$this->isContentDirEmpty();
$templateData['has_content'] = $hasContent;
// Don't show site title link on guide page
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
// Load partials manually
$hasContent = !$this->isContentDirEmpty() && !isset($_GET['guide']);
$headerContent = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
if (!$hasContent) {
// Remove the link from header when no content
$headerContent = preg_replace('/<a href="[^"]*" class="site-title-link">\s*<h1[^>]*>(.*?)<\/h1>\s*<\/a>/', '<h1 class="h3 mb-0">$1</h1>', $headerContent);
}
$footerContent = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
if (!$hasContent) {
// Remove guide link from footer when no content
$footerContent = preg_replace('/<span class="file-details">\s*\|\s*<a href="\?guide"[^>]*>Handleiding<\/a><\/span>/', '', $footerContent);
}
$partials = [
'navigation' => file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache'),
'footer' => file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache')
];
// Replace partials in template
$template = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
$template = str_replace('{{>navigation}}', $partials['navigation'], $template);
$template = str_replace('{{>footer}}', $partials['footer'], $template);
// Render template with data
echo SimpleTemplate::render($template, $templateData);
}
private function getBreadcrumb() {
if (isset($_GET['search'])) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '">Home</a></li><li class="breadcrumb-item active">Search</li></ol></nav>';
}
$page = $_GET['page'] ?? $this->config['default_page'];
$page = preg_replace('/\.[^.]+$/', '', $page);
if ($page === $this->config['default_page']) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item active">Home</li></ol></nav>';
}
$parts = explode('/', $page);
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '">Home</a></li>';
$path = '';
foreach ($parts as $i => $part) {
$path .= ($path ? '/' : '') . $part;
$title = ucfirst($part);
if ($i === count($parts) - 1) {
$breadcrumb .= '<li class="breadcrumb-item active">' . $title . '</li>';
} else {
$breadcrumb .= '<li class="breadcrumb-item"><a href="?page=' . $path . '">' . $title . '</a></li>';
}
}
$breadcrumb .= '</ol></nav>';
return $breadcrumb;
}
private function renderMenu($items, $level = 0) {
$html = '';
foreach ($items as $item) {
if ($item['type'] === 'folder') {
$hasChildren = !empty($item['children']);
if ($hasChildren) {
$folderId = 'folder-' . str_replace('/', '-', $item['path']);
$isExpanded = $this->folderContainsActivePage($item['children']);
$html .= '<li class="nav-item dropdown">';
$html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
$html .= htmlspecialchars($item['title']);
$html .= '</a>';
$html .= '<ul class="dropdown-menu" aria-labelledby="' . $folderId . '">';
$html .= $this->renderMenu($item['children'], $level + 1);
$html .= '</ul>';
$html .= '</li>';
} else {
$html .= '<li class="nav-item">';
$html .= '<span class="nav-link text-muted">' . htmlspecialchars($item['title']) . '</span>';
$html .= '</li>';
}
} else {
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
$html .= '</li>';
}
}
return $html;
}
} else {
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
$html .= '</li>';
}
}
return $html;
}
$html .= '</li>';
} else {
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link page-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
$html .= '</li>';
}
}
return $html;
}
private function folderContainsActivePage($children) {
foreach ($children as $child) {
if ($child['type'] === 'folder') {
if (!empty($child['children']) && $this->folderContainsActivePage($child['children'])) {
return true;
}
} else {
if (isset($_GET['page']) && $_GET['page'] === $child['path']) {
return true;
}
}
}
return false;
}
}

52
engine/router.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
// Router file for PHP development server to handle security and static files
$requestUri = $_SERVER['REQUEST_URI'];
$parsedUrl = parse_url($requestUri);
$path = $parsedUrl['path'];
// Block direct access to content directory
if (strpos($path, '/content/') === 0) {
http_response_code(403);
echo '<h1>403 - Forbidden</h1><p>Direct access to content files is not allowed.</p>';
return true;
}
// Block access to sensitive files
$sensitiveFiles = ['.htaccess', 'config.php'];
foreach ($sensitiveFiles as $file) {
if (basename($path) === $file && dirname($path) === '/') {
http_response_code(403);
echo '<h1>403 - Forbidden</h1><p>Access to this file is not allowed.</p>';
return true;
}
}
// Serve static files from engine/assets
if (strpos($path, '/engine/') === 0) {
$filePath = __DIR__ . $path;
if (file_exists($filePath)) {
// Set appropriate content type
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mimeTypes = [
'css' => 'text/css',
'js' => 'application/javascript',
'svg' => 'image/svg+xml',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf'
];
if (isset($mimeTypes[$extension])) {
header('Content-Type: ' . $mimeTypes[$extension]);
}
// Serve the file
readfile($filePath);
return true;
}
}
// Route all other requests to index.php
include __DIR__ . '/index.php';
return true;

View File

@@ -0,0 +1,26 @@
<footer class="bg-light border-top py-3">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center">
<div class="file-info">
<i class="bi bi-file-text"></i>
<span class="page-title" title="{{page_title}}">{{page_title}}</span>
{{#file_info}}
<span class="file-details"> | {{{file_info}}}</span>
{{/file_info}}
</div>
<div class="site-info">
<small class="text-muted">
<a href="?guide" class="guide-link" title="Handleiding">
<i class="bi bi-book"></i>
</a>
<span class="ms-2">|</span>
Powered by <a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener">CodePress CMS</a>
</small>
</div>
</div>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,23 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="?page={{default_page}}">
<img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
{{site_title}}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{{{menu}}}
</ul>
<form class="d-flex" method="GET" action="">
<input class="form-control me-2" type="search" name="search" placeholder="Search..." value="{{search_query}}">
<button class="btn btn-outline-light" type="submit">Search</button>
</form>
</div>
</div>
</nav>

View File

@@ -1,440 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - {{site_title}}</title>
<link rel="icon" type="image/svg+xml" href="/engine/assets/favicon.svg">
<link href="/engine/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/engine/assets/css/bootstrap-icons.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100vh - 70px); /* Minus header height */
}
.content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 250px;
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
overflow-y: auto;
flex-shrink: 0;
transition: transform 0.3s ease;
position: fixed;
top: 70px;
left: 0;
height: calc(100vh - 140px); /* 70px header + 70px footer */
z-index: 999;
transform: translateX(0);
}
.sidebar.collapsed {
transform: translateX(-250px);
}
.sidebar-toggle {
position: absolute;
top: 15px;
right: 15px;
z-index: 1001;
background: none;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 20px;
color: #6c757d;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.sidebar-toggle:hover {
color: #0d6efd;
background-color: #f8f9fa;
}
.sidebar-toggle-inner {
/* Toggle inside sidebar */
}
.sidebar-toggle-outer {
position: fixed;
top: 90px;
left: 20px;
z-index: 1001;
background-color: white;
border: 1px solid #dee2e6;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.sidebar.collapsed .sidebar-toggle-inner {
right: auto;
left: 15px;
}
.sidebar.collapsed ~ .main-content .sidebar-toggle-outer {
display: block !important;
}
.sidebar:not(.collapsed) ~ .main-content .sidebar-toggle-outer {
display: none !important;
}
.sidebar-toggle:hover {
background-color: #0a58ca;
transform: scale(1.05);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 20px;
transition: all 0.3s ease;
margin-left: 250px;
}
.sidebar.collapsed ~ .main-content {
margin-left: 0;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 20px;
transition: margin-left 0.3s ease;
}
.folder-toggle {
font-weight: bold;
color: #212529 !important;
cursor: pointer;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: #f8f9fa !important;
margin: 0.125rem 0;
transition: background-color 0.2s ease;
}
.folder-toggle:hover {
background-color: #e9ecef !important;
}
.folder-toggle[aria-expanded="true"] {
background-color: #dee2e6 !important;
color: #212529 !important;
font-weight: 600;
}
/* Progressive background colors for nested folders */
.nav .nav .folder-toggle {
background-color: #f1f3f4 !important;
}
.nav .nav .nav .folder-toggle {
background-color: #eaedee !important;
}
.nav .nav .nav .nav .folder-toggle {
background-color: #e3e7e8 !important;
}
.nav .nav .nav .nav .nav .folder-toggle {
background-color: #dce1e2 !important;
}
.nav .nav .nav .nav .nav .nav .folder-toggle {
background-color: #d5dbdd !important;
}
.nav .nav .nav .nav .nav .nav .nav .folder-toggle {
background-color: #ced5d8 !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .folder-toggle {
background-color: #c7cfd3 !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .nav .folder-toggle {
background-color: #c0c9ce !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .nav .nav .folder-toggle {
background-color: #b9c3c9 !important;
}
.folder-toggle .arrow {
margin-right: 8px;
transition: transform 0.2s;
font-size: 0.8em;
}
.folder-toggle[aria-expanded="true"] .arrow {
transform: rotate(90deg);
}
.page-link {
color: #495057 !important;
font-weight: 500;
padding: 0.5rem 0.75rem;
padding-left: 2rem;
background-color: #ffffff !important;
margin: 0.125rem 0;
transition: background-color 0.2s ease;
}
/* Progressive background colors for nested pages */
.nav .nav .page-link {
background-color: #fafbfc !important;
}
.nav .nav .nav .page-link {
background-color: #f5f7f8 !important;
}
.nav .nav .nav .nav .page-link {
background-color: #f0f3f4 !important;
}
.nav .nav .nav .nav .nav .page-link {
background-color: #ebefef !important;
}
.nav .nav .nav .nav .nav .nav .page-link {
background-color: #e6eaea !important;
}
.nav .nav .nav .nav .nav .nav .nav .page-link {
background-color: #e1e5e5 !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .page-link {
background-color: #dce0e0 !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .nav .page-link {
background-color: #d7dbdb !important;
}
.nav .nav .nav .nav .nav .nav .nav .nav .nav .nav .page-link {
background-color: #d2d6d6 !important;
}
.page-link:hover {
color: #212529 !important;
background-color: #f8f9fa !important;
}
.nav-link.active {
background-color: #0d6efd !important;
color: #212529 !important;
font-weight: 600;
}
.file-info {
font-size: 0.9rem;
color: #6c757d;
}
.site-info a {
color: #0d6efd;
text-decoration: none;
}
.site-info a:hover {
text-decoration: underline;
}
.auto-link {
color: #0d6efd;
text-decoration: none;
border-bottom: 2px dashed #0d6efd;
font-weight: 500;
transition: all 0.2s ease;
}
.auto-link:hover {
color: #0a58ca;
text-decoration: none;
border-bottom-style: solid;
border-bottom-color: #0a58ca;
}
.search-form {
max-width: 300px;
}
.card-title a {
text-decoration: none;
color: inherit;
}
.card-title a:hover {
text-decoration: underline;
}
.folder-disabled {
font-weight: 500;
color: #6c757d !important;
cursor: not-allowed;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: #f8f9fa !important;
margin: 0.125rem 0;
opacity: 0.7;
}
.folder-disabled .arrow {
margin-right: 8px;
font-size: 0.8em;
opacity: 0.6;
}
@media (max-width: 768px) {
.sidebar {
width: 200px;
}
}
</style>
</head>
<body>
<header class="bg-primary text-white py-3">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col">
<div class="d-flex align-items-center">
<img src="/engine/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
<h1 class="h3 mb-0">{{site_title}}</h1>
</div>
</div>
<div class="col-auto">
<form class="d-flex" method="GET" action="">
<input class="form-control me-2" type="search" name="search" placeholder="Search..." value="{{search_query}}">
<button class="btn btn-outline-light" type="submit">Search</button>
</form>
</div>
</div>
</div>
</header>
<div class="main-wrapper">
<div class="content-wrapper">
<nav class="sidebar" id="sidebar">
<div class="sidebar-toggle sidebar-toggle-inner" id="sidebarToggleInner">
<i class="bi bi-list"></i>
</div>
<div class="pt-3">
<ul class="nav flex-column">
{{menu}}
</ul>
</div>
</nav>
<main class="main-content">
<div class="sidebar-toggle sidebar-toggle-outer" id="sidebarToggleOuter" style="display: none;">
<i class="bi bi-list"></i>
</div>
<div>
{{breadcrumb}}
</div>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h2>{{page_title}}</h2>
</div>
<div class="content">
{{content}}
</div>
</main>
</div>
</div>
<footer class="bg-light border-top py-3" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 998;">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center">
<div class="file-info">
{{file_info}}
</div>
<div class="site-info">
<small class="text-muted">Powered by <a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener">CodePress CMS</a></small>
</div>
</div>
</div>
</div>
</div>
</footer>
<script src="/engine/assets/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Sidebar toggle functionality
const sidebarToggleInner = document.getElementById('sidebarToggleInner');
const sidebarToggleOuter = document.getElementById('sidebarToggleOuter');
const sidebar = document.getElementById('sidebar');
// Initialize sidebar state (open by default)
sidebar.classList.remove('collapsed');
const innerIcon = sidebarToggleInner.querySelector('i');
const outerIcon = sidebarToggleOuter.querySelector('i');
innerIcon.classList.remove('bi-list');
innerIcon.classList.add('bi-x');
outerIcon.classList.remove('bi-list');
outerIcon.classList.add('bi-x');
function toggleSidebar() {
sidebar.classList.toggle('collapsed');
// Change icons
if (sidebar.classList.contains('collapsed')) {
innerIcon.classList.remove('bi-x');
innerIcon.classList.add('bi-list');
outerIcon.classList.remove('bi-x');
outerIcon.classList.add('bi-list');
} else {
innerIcon.classList.remove('bi-list');
innerIcon.classList.add('bi-x');
outerIcon.classList.remove('bi-list');
outerIcon.classList.add('bi-x');
}
}
sidebarToggleInner.addEventListener('click', toggleSidebar);
sidebarToggleOuter.addEventListener('click', toggleSidebar);
// Folders are now automatically expanded by PHP if they contain the active page
// Close other folders when opening a new one
const folderToggles = document.querySelectorAll('.folder-toggle');
folderToggles.forEach(toggle => {
toggle.addEventListener('click', function(e) {
const targetId = this.getAttribute('data-bs-target');
const targetCollapse = document.querySelector(targetId);
const isExpanded = this.getAttribute('aria-expanded') === 'true';
if (!isExpanded && targetCollapse) {
// Close all other folders first
folderToggles.forEach(otherToggle => {
if (otherToggle !== this) {
const otherTargetId = otherToggle.getAttribute('data-bs-target');
if (otherTargetId) {
const otherCollapse = document.querySelector(otherTargetId);
if (otherCollapse) {
otherCollapse.classList.remove('show');
otherToggle.setAttribute('aria-expanded', 'false');
// Reset arrow
const otherArrow = otherToggle.querySelector('.arrow');
if (otherArrow) {
otherArrow.style.transform = '';
}
}
}
}
});
// Open this folder
targetCollapse.classList.add('show');
this.setAttribute('aria-expanded', 'true');
// Rotate arrow
const arrow = this.querySelector('.arrow');
if (arrow) {
arrow.style.transform = 'rotate(90deg)';
}
} else if (isExpanded && targetCollapse) {
// Close this folder
targetCollapse.classList.remove('show');
this.setAttribute('aria-expanded', 'false');
// Reset arrow
const arrow = this.querySelector('.arrow');
if (arrow) {
arrow.style.transform = '';
}
}
});
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - {{site_title}}</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/bootstrap-icons.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
</head>
<body>
{{>header}}
<div class="main-wrapper">
{{>navigation}}
<main class="main-content">
<div class="content-inner">
<div>
{{{breadcrumb}}}
</div>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h2>{{page_title}}</h2>
</div>
<div class="content">
{{{content}}}
</div>
</div>
</main>
</div>
{{>footer}}
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/sidebar.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
{{#content}}
<p>{{{.}}}</p>
{{/content}}

View File

@@ -0,0 +1 @@
{{{content}}}