361 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|