Add user dropdown menu and notification panel to topbar

- Click user section to open dropdown with profile, settings, password, sign out
- Shows user avatar, name, and email in dropdown header
- Click notification bell to open panel showing empty state
- Both use portal-rendered dropdowns, close on outside click
- Removed standalone logout button, moved into user dropdown
This commit is contained in:
Adam Moussa 2026-04-16 19:21:26 -04:00
parent 11e3d3d31f
commit 2477bcf5a6
2 changed files with 261 additions and 23 deletions

View file

@ -1,16 +1,57 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { logout, getCurrentUser } from "../services/authService";
export default function Topbar({ onToggleNav }) {
const navigate = useNavigate();
const currentUser = getCurrentUser();
function NotificationDropdown() {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const menuRef = useRef(null);
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
const handleLogout = () => {
logout();
navigate("/login");
useEffect(() => {
const handleClick = (e) => {
if (btnRef.current?.contains(e.target)) return;
if (menuRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleOpen = () => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right });
}
setOpen(!open);
};
return (
<>
<button ref={btnRef} className="topbarIconBtn" title="Notifications" onClick={handleOpen}>
<i className="fa fa-bell-o"></i>
</button>
{open && ReactDOM.createPortal(
<div ref={menuRef} className="notifDropdown" style={{ top: menuPos.top, right: menuPos.right }}>
<div className="notifHeader">Notifications</div>
<div className="notifEmpty">
<i className="fa fa-check-circle notifEmptyIcon"></i>
<span>No new notifications</span>
</div>
</div>,
document.body
)}
</>
);
}
function UserDropdown({ currentUser, onLogout, onNavigate }) {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const menuRef = useRef(null);
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
const getInitials = (fullname) => {
if (!fullname) return "U";
const parts = fullname.split(" ");
@ -20,6 +61,78 @@ export default function Topbar({ onToggleNav }) {
return fullname.substring(0, 2).toUpperCase();
};
useEffect(() => {
const handleClick = (e) => {
if (btnRef.current?.contains(e.target)) return;
if (menuRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleOpen = () => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right });
}
setOpen(!open);
};
const handleNav = (path) => {
setOpen(false);
onNavigate(path);
};
return (
<>
<button ref={btnRef} className="topbarUserBtn" onClick={handleOpen}>
<div className="avatar">{getInitials(currentUser?.fullname)}</div>
<div className="topbarUserInfo">
<span className="topbarUserName">{currentUser?.fullname || "User"}</span>
<span className="topbarUserRole">{currentUser?.userRoles || ""}</span>
</div>
<i className="fa fa-angle-down topbarUserCaret"></i>
</button>
{open && ReactDOM.createPortal(
<div ref={menuRef} className="userDropdown" style={{ top: menuPos.top, right: menuPos.right }}>
<div className="userDropdownHeader">
<div className="userDropdownAvatar">{getInitials(currentUser?.fullname)}</div>
<div>
<div className="userDropdownName">{currentUser?.fullname || "User"}</div>
<div className="userDropdownEmail">{currentUser?.email || ""}</div>
</div>
</div>
<div className="userDropdownDivider" />
<button className="userDropdownItem" onClick={() => handleNav("/profile")}>
<i className="fa fa-user"></i> My Profile
</button>
<button className="userDropdownItem" onClick={() => handleNav("/settings")}>
<i className="fa fa-cog"></i> Account Settings
</button>
<button className="userDropdownItem" onClick={() => handleNav("/change-password")}>
<i className="fa fa-lock"></i> Change Password
</button>
<div className="userDropdownDivider" />
<button className="userDropdownItem userDropdownLogout" onClick={() => { setOpen(false); onLogout(); }}>
<i className="fa fa-sign-out"></i> Sign Out
</button>
</div>,
document.body
)}
</>
);
}
export default function Topbar({ onToggleNav }) {
const navigate = useNavigate();
const currentUser = getCurrentUser();
const handleLogout = () => {
logout();
navigate("/login");
};
return (
<header className="topbar">
<div className="topLeft">
@ -34,23 +147,9 @@ export default function Topbar({ onToggleNav }) {
</div>
<div className="topRight">
<button className="topbarIconBtn" title="Notifications">
<i className="fa fa-bell-o"></i>
</button>
<NotificationDropdown />
<div className="topbarDivider" />
<div className="topbarUser">
<div className="avatar">{getInitials(currentUser?.fullname)}</div>
<div className="topbarUserInfo">
<span className="topbarUserName">{currentUser?.fullname || "User"}</span>
<span className="topbarUserRole">{currentUser?.userRoles || ""}</span>
</div>
</div>
<button className="topbarIconBtn topbarLogout" onClick={handleLogout} title="Logout">
<i className="fa fa-sign-out"></i>
</button>
<UserDropdown currentUser={currentUser} onLogout={handleLogout} onNavigate={navigate} />
</div>
</header>
);

View file

@ -263,6 +263,145 @@ body {
background: rgba(255,100,100,0.1);
}
.notifDropdown {
position: fixed;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 99999;
width: 300px;
overflow: hidden;
}
.notifHeader {
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
color: #333;
border-bottom: 1px solid #eee;
}
.notifEmpty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 30px 16px;
color: #999;
font-size: 13px;
}
.notifEmptyIcon {
font-size: 24px;
color: #ccc;
}
.topbarUserBtn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.topbarUserBtn:hover {
background: rgba(255,255,255,0.1);
}
.topbarUserCaret {
color: rgba(255,255,255,0.5);
font-size: 12px;
margin-left: 2px;
}
.userDropdown {
position: fixed;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 99999;
width: 260px;
overflow: hidden;
}
.userDropdownHeader {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.userDropdownAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #1fa8de;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: #fff;
flex-shrink: 0;
}
.userDropdownName {
font-size: 14px;
font-weight: 600;
color: #333;
}
.userDropdownEmail {
font-size: 12px;
color: #999;
}
.userDropdownDivider {
height: 1px;
background: #eee;
}
.userDropdownItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 16px;
border: none;
background: #fff;
font-size: 13px;
color: #333;
cursor: pointer;
text-align: left;
}
.userDropdownItem:hover {
background: #f5f5f5;
}
.userDropdownItem i {
width: 16px;
text-align: center;
color: #999;
}
.userDropdownLogout {
color: #dc3545;
}
.userDropdownLogout:hover {
background: #fff5f5;
}
.userDropdownLogout i {
color: #dc3545;
}
.topbarDivider {
width: 1px;
height: 24px;