Files
alrahma_web_client/src/layout/MainLayout.tsx
T
root b895c06dc1
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
fix parent pages and teachers and admin
2026-07-09 13:56:26 -04:00

361 lines
15 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
const parentNavItems = [
{ to: '/app/parent/home', icon: 'bi bi-house-door', label: 'Dashboard' },
{ to: '/app/parent/attendance', icon: 'bi bi-calendar-check', label: 'Attendance' },
{ to: '/app/parent/calendar', icon: 'bi bi-calendar2-week', label: 'Calendar' },
{ to: '/app/parent/progress', icon: 'bi bi-card-checklist', label: 'Class Progress' },
{ to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
{ to: '/app/parent/register-student', icon: 'bi bi-person-plus', label: 'Register Students' },
{ to: '/app/parent/report-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
{ to: '/app/parent/scores', icon: 'bi bi-clipboard-data', label: 'Scores' },
] as const
/** Teacher top shortcuts (aligned with legacy teacher navbar). */
const teacherNavItems = [
{ to: '/app/teacher_dashboard', icon: 'bi bi-house-door', label: 'Dashboard' },
{ to: '/app/teacher/showupdate-attendance', icon: 'bi bi-calendar-check', label: 'Attendance' },
{ to: '/app/teacher/inventory/book-distribute', icon: 'bi bi-book', label: 'Books' },
{ to: '/app/teacher/calendar', icon: 'bi bi-calendar2-week', label: 'Calendar' },
{ to: '/app/teacher/class-view', icon: 'bi bi-pencil-square', label: 'Class' },
{ to: '/app/teacher/class-progress-submit', icon: 'bi bi-card-checklist', label: 'Class Progress' },
{ to: '/app/teacher/competition-scores', icon: 'bi bi-trophy', label: 'Competitions' },
{ to: '/app/teacher/exam-drafts', icon: 'bi bi-file-earmark-text', label: 'Exam Drafts' },
{ to: '/app/teacher/print-requests', icon: 'bi bi-printer', label: 'Print' },
{ to: '/app/student/score-card/list', icon: 'bi bi-card-text', label: 'Score Card' },
{ to: '/app/teacher/scores', icon: 'bi bi-bar-chart-line', label: 'Scores' },
{ to: '/app/teacher/absence-vacation', icon: 'bi bi-megaphone', label: 'TimeOff' },
] as const
function initialsFromName(name: string | undefined | null): string {
const t = name?.trim()
if (!t) return '?'
const parts = t.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
const a = parts[0][0] ?? ''
const b = parts[parts.length - 1][0] ?? ''
return (a + b).toUpperCase()
}
if (parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] ?? '?').toUpperCase()
}
function formatRoleLabel(slug: string): string {
const t = slug.trim()
if (!t) return ''
return t.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}
export function MainLayout() {
const { user, logout, selectedRole, roles } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [navOpen, setNavOpen] = useState(false)
const [portalStyle, setPortalStyle] = useState<PortalStylePrefs>(readPortalStylePrefs)
const [osPrefersDark, setOsPrefersDark] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clusterRef = useRef<HTMLDivElement>(null)
const userMenuTriggerRef = useRef<HTMLButtonElement>(null)
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}, [])
const openUserMenu = useCallback(() => {
clearCloseTimer()
setUserMenuOpen(true)
}, [clearCloseTimer])
const closeUserMenu = useCallback(() => {
clearCloseTimer()
const root = clusterRef.current
if (root?.contains(document.activeElement)) {
userMenuTriggerRef.current?.focus()
}
setUserMenuOpen(false)
}, [clearCloseTimer])
const scheduleCloseUserMenu = useCallback(() => {
clearCloseTimer()
closeTimerRef.current = setTimeout(() => {
const root = clusterRef.current
if (root?.contains(document.activeElement)) {
closeTimerRef.current = null
return
}
closeUserMenu()
closeTimerRef.current = null
}, 200)
}, [clearCloseTimer, closeUserMenu])
useEffect(() => () => clearCloseTimer(), [clearCloseTimer])
const onClusterBlurCapture = useCallback(() => {
window.setTimeout(() => {
const root = clusterRef.current
if (!root || !root.contains(document.activeElement)) {
closeUserMenu()
}
}, 0)
}, [closeUserMenu])
const onAvatarClick = useCallback(() => {
if (typeof window === 'undefined') return
if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
setUserMenuOpen((open) => !open)
}
}, [])
const closeMenus = useCallback(() => {
closeUserMenu()
setNavOpen(false)
}, [closeUserMenu])
useEffect(() => {
const sync = () => setPortalStyle(readPortalStylePrefs())
window.addEventListener('alrahma-portal-style', sync)
return () => window.removeEventListener('alrahma-portal-style', sync)
}, [])
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => setOsPrefersDark(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
const role = selectedRole ?? roles[0] ?? ''
const canSwitchRole = roles.length > 1
const dashboardPath =
isParentPortalRole(role)
? '/app/parent/home'
: isTeacherPortalRole(role)
? '/app/teacher_dashboard'
: '/app/home'
const mainNavItems = isParentPortalRole(role)
? parentNavItems
: isTeacherPortalRole(role)
? teacherNavItems
: []
const initials = initialsFromName(user?.name)
const resolvedColorMode = resolvePortalColorMode(portalStyle, osPrefersDark)
return (
<div
className="main-layout-app d-flex flex-column min-vh-100"
data-portal-density={portalStyle.density}
data-portal-font-scale={portalStyle.fontScale}
data-portal-font-family={portalStyle.fontFamily}
data-portal-accent={portalStyle.accent}
data-portal-color-mode={resolvedColorMode}
>
<header className="navbar navbar-expand-lg custom-navbar sticky-top shadow-sm px-3">
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
</Link>
<button
className="navbar-toggler"
type="button"
aria-controls="mainLayoutNavbar"
aria-expanded={navOpen}
aria-label="Toggle navigation"
onClick={() => setNavOpen((open) => !open)}
>
<span className="navbar-toggler-icon" />
</button>
<div id="mainLayoutNavbar" className={`collapse navbar-collapse ${navOpen ? 'show' : ''}`}>
<nav
className="ms-lg-auto me-lg-2 my-2 my-lg-0 main-layout-menu"
aria-label={
isTeacherPortalRole(role)
? 'Teacher portal navigation'
: isParentPortalRole(role)
? 'Parent portal navigation'
: 'Portal navigation'
}
>
<ul className="navbar-nav list-unstyled parent-navbar-menu mb-0">
{mainNavItems.map((item) => (
<li key={item.to} className="nav-item">
<NavLink
to={item.to}
end={
item.to === '/app/teacher_dashboard' ||
item.to === '/app/parent/home' ||
item.to === '/app/student/score-card/list'
}
className={({ isActive }) =>
`nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`
}
onClick={() => setNavOpen(false)}
>
<i className={item.icon} aria-hidden />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</nav>
{user ? (
<div className="d-none d-lg-flex align-items-center me-2">
<SchoolYearSelector compact />
</div>
) : null}
{user ? (
<div
ref={clusterRef}
className="main-layout-user-cluster d-flex align-items-center ms-lg-2"
onMouseEnter={openUserMenu}
onMouseLeave={scheduleCloseUserMenu}
onFocusCapture={openUserMenu}
onBlurCapture={onClusterBlurCapture}
>
<div className="main-layout-user-avatar-wrap">
<button
ref={userMenuTriggerRef}
type="button"
className="main-layout-user-trigger"
id="main-layout-user-trigger"
aria-haspopup="true"
aria-expanded={userMenuOpen}
aria-controls="main-layout-user-menu"
onClick={onAvatarClick}
>
<div className="main-layout-user-avatar rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center">
<span className="main-layout-user-initials">{initials}</span>
</div>
</button>
{canSwitchRole ? (
<Link
to="/app/select-role"
state={{ from: location.pathname }}
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--roles"
title="Switch role"
aria-label="Switch role"
onClick={closeMenus}
>
<i className="bi bi-arrow-left-right" aria-hidden />
</Link>
) : null}
<Link
to="/app/portal-style"
className="main-layout-user-avatar-chip main-layout-user-avatar-chip--style"
title="Style edit"
aria-label="Style edit"
onClick={closeMenus}
>
<i className="bi bi-palette" aria-hidden />
</Link>
</div>
<div
id="main-layout-user-menu"
role="region"
aria-labelledby="main-layout-user-trigger"
aria-hidden={!userMenuOpen}
className={`main-layout-user-dropdown card border-0 shadow-sm py-2 ${userMenuOpen ? 'is-open' : ''}`}
>
<div className="px-3 pb-1">
<div className="main-layout-user-dropdown-header">Signed in as</div>
<div className="small text-dark text-truncate fw-semibold" title={user.name}>
{user.name}
</div>
{role ? (
<>
<div className="main-layout-user-dropdown-header mt-2">Role</div>
<div
className="small text-dark text-truncate fw-semibold main-layout-user-dropdown-role"
title={formatRoleLabel(role)}
>
{formatRoleLabel(role)}
</div>
</>
) : null}
</div>
<div className="dropdown-divider mx-2 my-1" />
{canSwitchRole ? (
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/select-role"
state={{ from: location.pathname }}
onClick={closeMenus}
>
<i className="bi bi-arrow-left-right" aria-hidden />
Switch role
</Link>
) : null}
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/portal-style"
onClick={closeMenus}
>
<i className="bi bi-palette" aria-hidden />
Style edit
</Link>
<Link
className="dropdown-item d-flex align-items-center gap-2 py-2"
to="/app/account"
onClick={closeMenus}
>
<i className="bi bi-gear" aria-hidden />
Settings
</Link>
<div className="dropdown-divider mx-2 my-1" />
<button
type="button"
className="dropdown-item d-flex align-items-center gap-2 py-2 text-danger"
onClick={() => {
closeMenus()
logout()
navigate('/login', { replace: true })
}}
>
<i className="bi bi-box-arrow-right" aria-hidden />
Log out
</button>
</div>
</div>
) : null}
</div>
</div>
</header>
<main className="container mt-4 flex-grow-1">
<ReadOnlyBanner />
<Outlet />
</main>
<CiPublicFooter />
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
<div className="modal-dialog modal-xl modal-dialog-scrollable">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Family</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div className="modal-body p-0" id="familyCardContent" />
</div>
</div>
</div>
</div>
)
}