Implement modern sidebar navigation with hamburger menu

- Add responsive sidebar with hamburger toggle functionality
- Implement dual toggle buttons (inner/outer) for better UX
- Fix sidebar positioning to not overlap header and footer
- Add sticky footer with proper z-index layering
- Download and integrate Bootstrap source maps locally
- Optimize toggle icons: smaller, cleaner, no button styling
- Ensure sidebar respects footer boundaries
- Add smooth transitions and hover effects
- Fix active page highlighting and folder auto-expansion
- Create professional W3Schools-style navigation
- Maintain full offline capability with local assets
This commit is contained in:
Edwin Noorlander 2025-11-19 18:02:48 +01:00
parent 494ae7dc3b
commit 0f1c7234b8
10 changed files with 171 additions and 79 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -427,10 +427,16 @@ private function autoLinkPageTitles($content) {
if ($hasChildren) { if ($hasChildren) {
$folderId = 'folder-' . str_replace('/', '-', $item['path']); $folderId = 'folder-' . str_replace('/', '-', $item['path']);
$html .= '<span class="nav-link folder-toggle" data-bs-toggle="collapse" data-bs-target="#' . $folderId . '" aria-expanded="false">';
// 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 .= '<i class="arrow bi bi-chevron-right"></i> ' . htmlspecialchars($item['title']);
$html .= '</span>'; $html .= '</span>';
$html .= '<ul class="nav flex-column ms-2 collapse" id="' . $folderId . '">'; $html .= '<ul class="nav flex-column ms-2 ' . $collapseClass . '" id="' . $folderId . '">';
$html .= $this->renderMenu($item['children'], $level + 1); $html .= $this->renderMenu($item['children'], $level + 1);
$html .= '</ul>'; $html .= '</ul>';
} else { } else {
@ -449,6 +455,21 @@ private function autoLinkPageTitles($content) {
} }
return $html; 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;
}
} }
$cms = new CodePressCMS($config); $cms = new CodePressCMS($config);

View File

@ -4,9 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - {{site_title}}</title> <title>{{page_title}} - {{site_title}}</title>
<link rel="icon" type="image/svg+xml" href="../engine/assets/favicon.svg"> <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.min.css" rel="stylesheet">
<link href="../engine/assets/css/bootstrap-icons.css" rel="stylesheet"> <link href="/engine/assets/css/bootstrap-icons.css" rel="stylesheet">
<style> <style>
* { * {
margin: 0; margin: 0;
@ -22,12 +22,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
position: relative;
} }
.main-wrapper { .main-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: calc(100vh - 70px); /* Minus header height */
} }
.content-wrapper { .content-wrapper {
@ -43,34 +45,72 @@
overflow-y: auto; overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.3s ease; transition: transform 0.3s ease;
position: relative; position: fixed;
z-index: 1000; top: 70px;
left: 0;
height: calc(100vh - 140px); /* 70px header + 70px footer */
z-index: 999;
transform: translateX(0);
} }
.sidebar.collapsed { .sidebar.collapsed {
transform: translateX(-250px); transform: translateX(-250px);
} }
.sidebar-toggle { .sidebar-toggle {
position: fixed; position: absolute;
top: 80px; top: 15px;
left: 10px; right: 15px;
z-index: 1001; z-index: 1001;
background-color: #0d6efd; background: none;
color: white;
border: none; border: none;
border-radius: 5px;
padding: 8px 12px;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.3s ease; 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 { .sidebar-toggle:hover {
background-color: #0a58ca; background-color: #0a58ca;
transform: scale(1.05); transform: scale(1.05);
} }
.sidebar-toggle.shifted { .main-content {
left: 270px; flex: 1;
overflow-y: auto;
padding: 20px;
transition: all 0.3s ease;
margin-left: 250px;
} }
.main-content.shifted { .sidebar.collapsed ~ .main-content {
margin-left: 0; margin-left: 0;
} }
@ -246,7 +286,7 @@
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img src="../engine/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2"> <img src="/engine/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
<h1 class="h3 mb-0">{{site_title}}</h1> <h1 class="h3 mb-0">{{site_title}}</h1>
</div> </div>
</div> </div>
@ -262,10 +302,10 @@
<div class="main-wrapper"> <div class="main-wrapper">
<div class="content-wrapper"> <div class="content-wrapper">
<button class="sidebar-toggle" id="sidebarToggle">
<i class="bi bi-list"></i>
</button>
<nav class="sidebar" id="sidebar"> <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"> <div class="pt-3">
<ul class="nav flex-column"> <ul class="nav flex-column">
{{menu}} {{menu}}
@ -274,6 +314,9 @@
</nav> </nav>
<main class="main-content"> <main class="main-content">
<div class="sidebar-toggle sidebar-toggle-outer" id="sidebarToggleOuter" style="display: none;">
<i class="bi bi-list"></i>
</div>
<div> <div>
{{breadcrumb}} {{breadcrumb}}
</div> </div>
@ -287,7 +330,7 @@
</div> </div>
</div> </div>
<footer class="bg-light border-top py-3"> <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="container-fluid">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@ -304,74 +347,90 @@
</div> </div>
</footer> </footer>
<script src="../engine/assets/js/bootstrap.bundle.min.js"></script> <script src="/engine/assets/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Sidebar toggle functionality // Sidebar toggle functionality
const sidebarToggle = document.getElementById('sidebarToggle'); const sidebarToggleInner = document.getElementById('sidebarToggleInner');
const sidebarToggleOuter = document.getElementById('sidebarToggleOuter');
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const mainContent = document.querySelector('.main-content');
sidebarToggle.addEventListener('click', function() { // 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'); sidebar.classList.toggle('collapsed');
mainContent.classList.toggle('shifted');
sidebarToggle.classList.toggle('shifted');
// Change icon // Change icons
const icon = this.querySelector('i');
if (sidebar.classList.contains('collapsed')) { if (sidebar.classList.contains('collapsed')) {
icon.classList.remove('bi-list'); innerIcon.classList.remove('bi-x');
icon.classList.add('bi-chevron-right'); innerIcon.classList.add('bi-list');
outerIcon.classList.remove('bi-x');
outerIcon.classList.add('bi-list');
} else { } else {
icon.classList.remove('bi-chevron-right'); innerIcon.classList.remove('bi-list');
icon.classList.add('bi-list'); innerIcon.classList.add('bi-x');
} outerIcon.classList.remove('bi-list');
}); outerIcon.classList.add('bi-x');
// Open folders that contain the current active page
const activeLink = document.querySelector('.nav-link.active');
if (activeLink) {
let parent = activeLink.closest('.collapse');
while (parent) {
const toggle = document.querySelector('[data-bs-target="#' + parent.id + '"]');
if (toggle) {
const collapse = new bootstrap.Collapse(parent, {
show: true
});
toggle.setAttribute('aria-expanded', 'true');
}
parent = parent.parentElement.closest('.collapse');
} }
} }
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 // Close other folders when opening a new one
const folderToggles = document.querySelectorAll('.folder-toggle'); const folderToggles = document.querySelectorAll('.folder-toggle');
folderToggles.forEach(toggle => { folderToggles.forEach(toggle => {
toggle.addEventListener('click', function(e) { toggle.addEventListener('click', function(e) {
const targetId = this.getAttribute('data-bs-target'); const targetId = this.getAttribute('data-bs-target');
const targetCollapse = document.querySelector(targetId);
const isExpanded = this.getAttribute('aria-expanded') === 'true'; const isExpanded = this.getAttribute('aria-expanded') === 'true';
if (!isExpanded) { if (!isExpanded && targetCollapse) {
// Close all other folders // Close all other folders first
folderToggles.forEach(otherToggle => { folderToggles.forEach(otherToggle => {
if (otherToggle !== this) { if (otherToggle !== this) {
const otherTargetId = otherToggle.getAttribute('data-bs-target'); const otherTargetId = otherToggle.getAttribute('data-bs-target');
if (otherTargetId) { if (otherTargetId) {
const otherCollapse = document.querySelector(otherTargetId); const otherCollapse = document.querySelector(otherTargetId);
if (otherCollapse) { if (otherCollapse) {
const bsCollapse = bootstrap.Collapse.getInstance(otherCollapse); otherCollapse.classList.remove('show');
if (bsCollapse) {
bsCollapse.hide();
} else {
new bootstrap.Collapse(otherCollapse, {
hide: true
});
}
otherToggle.setAttribute('aria-expanded', 'false'); 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 = '';
}
} }
}); });
}); });

1
public/engine Symbolic link
View File

@ -0,0 +1 @@
../engine

View File

@ -438,7 +438,7 @@ class CodePressCMS {
} }
// Block direct access to content files // Block direct access to content files
$requestUri = $_SERVER['REQUEST_URI']; $requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($requestUri, '/content/') !== false) { if (strpos($requestUri, '/content/') !== false) {
http_response_code(403); http_response_code(403);
echo '<h1>403 - Forbidden</h1><p>Direct access to content files is not allowed.</p>'; echo '<h1>403 - Forbidden</h1><p>Direct access to content files is not allowed.</p>';

View File

@ -1,5 +1,5 @@
<?php <?php
// Router file for PHP development server to handle security // Router file for PHP development server to handle security and static files
$requestUri = $_SERVER['REQUEST_URI']; $requestUri = $_SERVER['REQUEST_URI'];
$parsedUrl = parse_url($requestUri); $parsedUrl = parse_url($requestUri);
@ -23,9 +23,30 @@ foreach ($sensitiveFiles as $file) {
} }
// Serve static files from engine/assets // Serve static files from engine/assets
if (strpos($path, '/engine/') === 0 && file_exists(__DIR__ . $path)) { if (strpos($path, '/engine/') === 0) {
return false; // Let PHP server serve the file $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 // Route all other requests to index.php
return false; // Let PHP server handle routing to index.php include __DIR__ . '/index.php';
return true;

View File

@ -1,13 +1 @@
[Wed Nov 19 16:58:13 2025] PHP 8.4.14 Development Server (http://localhost:8080) started [Wed Nov 19 17:58:28 2025] Failed to listen on localhost:8080 (reason: Address already in use)
[Wed Nov 19 16:58:22 2025] [::1]:43444 Accepted
[Wed Nov 19 16:58:22 2025] [::1]:43444 [200]: GET /
[Wed Nov 19 16:58:22 2025] [::1]:43444 Closing
[Wed Nov 19 16:58:26 2025] [::1]:59122 Accepted
[Wed Nov 19 16:58:26 2025] [::1]:59122 [200]: GET /content/home.md
[Wed Nov 19 16:58:26 2025] [::1]:59122 Closing
[Wed Nov 19 16:58:30 2025] [::1]:59138 Accepted
[Wed Nov 19 16:58:30 2025] [::1]:59138 [200]: GET /content/home.md
[Wed Nov 19 16:58:30 2025] [::1]:59138 Closing
[Wed Nov 19 16:59:06 2025] [::1]:38150 Accepted
[Wed Nov 19 16:59:06 2025] [::1]:38150 [200]: GET /content/home.md
[Wed Nov 19 16:59:06 2025] [::1]:38150 Closing