Initial commit: Parking Manager
Features: - Manager-centric parking spot management - Fair assignment algorithm (parking/presence ratio) - Presence tracking calendar - Closing days (specific & weekly recurring) - Guarantees and exclusions - Authelia/LLDAP integration for SSO Stack: - FastAPI backend - SQLite database - Vanilla JS frontend - Docker deployment
This commit is contained in:
183
frontend/js/nav.js
Normal file
183
frontend/js/nav.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Navigation Component
|
||||
* Sidebar navigation and user menu
|
||||
*/
|
||||
|
||||
const MENU_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
calendar: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>`,
|
||||
users: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>`,
|
||||
rules: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>`,
|
||||
user: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>`,
|
||||
building: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="2" width="12" height="20"></rect>
|
||||
<rect x="9" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="9" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/presence', icon: 'calendar', label: 'My Presence' },
|
||||
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] },
|
||||
{ href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] },
|
||||
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
|
||||
];
|
||||
|
||||
function getIcon(name) {
|
||||
return ICONS[name] || '';
|
||||
}
|
||||
|
||||
function canAccessNavItem(item, userRole) {
|
||||
if (!item.roles || item.roles.length === 0) return true;
|
||||
return userRole && item.roles.includes(userRole);
|
||||
}
|
||||
|
||||
function renderNav(currentPath, userRole) {
|
||||
return NAV_ITEMS
|
||||
.filter(item => canAccessNavItem(item, userRole))
|
||||
.map(item => {
|
||||
const isActive = item.href === currentPath ||
|
||||
(currentPath !== '/' && item.href !== '/' && currentPath.startsWith(item.href));
|
||||
const activeClass = isActive ? ' active' : '';
|
||||
|
||||
return `<a href="${item.href}" class="nav-item${activeClass}">
|
||||
${getIcon(item.icon)}
|
||||
<span>${item.label}</span>
|
||||
</a>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
async function initNav() {
|
||||
const navContainer = document.querySelector('.sidebar-nav');
|
||||
if (!navContainer) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
let userRole = null;
|
||||
let currentUser = null;
|
||||
|
||||
// Get user info
|
||||
if (api.isAuthenticated()) {
|
||||
currentUser = await api.getCurrentUser();
|
||||
if (currentUser) {
|
||||
userRole = currentUser.role;
|
||||
}
|
||||
}
|
||||
|
||||
// Render navigation
|
||||
navContainer.innerHTML = renderNav(currentPath, userRole);
|
||||
|
||||
// Update user info in sidebar
|
||||
if (currentUser) {
|
||||
const userName = document.getElementById('userName');
|
||||
const userRole = document.getElementById('userRole');
|
||||
if (userName) userName.textContent = currentUser.name || 'User';
|
||||
if (userRole) userRole.textContent = currentUser.role || '-';
|
||||
}
|
||||
|
||||
// Setup user menu
|
||||
setupUserMenu();
|
||||
|
||||
// Setup mobile menu
|
||||
setupMobileMenu();
|
||||
}
|
||||
|
||||
function setupMobileMenu() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const pageHeader = document.querySelector('.page-header');
|
||||
if (!sidebar || !pageHeader) return;
|
||||
|
||||
// Add menu toggle button to page header (at the start)
|
||||
const menuToggle = document.createElement('button');
|
||||
menuToggle.className = 'menu-toggle';
|
||||
menuToggle.innerHTML = MENU_ICON;
|
||||
menuToggle.setAttribute('aria-label', 'Toggle menu');
|
||||
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
|
||||
|
||||
// Add overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'sidebar-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Toggle sidebar
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('open');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking overlay
|
||||
overlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking a nav item (on mobile)
|
||||
sidebar.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupUserMenu() {
|
||||
const userMenuButton = document.getElementById('userMenuButton');
|
||||
const userDropdown = document.getElementById('userDropdown');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
|
||||
if (userMenuButton && userDropdown) {
|
||||
userMenuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = userDropdown.style.display === 'block';
|
||||
userDropdown.style.display = isOpen ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
userDropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
userDropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
api.logout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.getIcon = getIcon;
|
||||
|
||||
// Auto-initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initNav);
|
||||
} else {
|
||||
initNav();
|
||||
}
|
||||
Reference in New Issue
Block a user