Merge pull request 'Add dropdown menus for user and admin actions' (#21) from feature/user-nav-menu into master
Some checks failed
CI / checks (push) Has been cancelled

Reviewed-on: #21
This commit is contained in:
Tero Halla-aho 2025-12-21 23:16:30 +02:00
commit 464e6687e4

View file

@ -105,6 +105,12 @@ function Icon({ name }: { name: string }) {
<path d="M9.5 12.5l1.8 1.8 3.7-3.7" /> <path d="M9.5 12.5l1.8 1.8 3.7-3.7" />
</svg> </svg>
); );
case 'chevron-down':
return (
<svg {...common} viewBox="0 0 24 24" aria-hidden>
<path d="M6 9l6 6 6-6" />
</svg>
);
default: default:
return null; return null;
} }
@ -115,7 +121,9 @@ export default function NavBar() {
const [user, setUser] = useState<SessionUser | null>(null); const [user, setUser] = useState<SessionUser | null>(null);
const [pendingCount, setPendingCount] = useState<number>(0); const [pendingCount, setPendingCount] = useState<number>(0);
const [adminMenuOpen, setAdminMenuOpen] = useState(false); const [adminMenuOpen, setAdminMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const adminMenuRef = useRef<HTMLDivElement | null>(null); const adminMenuRef = useRef<HTMLDivElement | null>(null);
const userMenuRef = useRef<HTMLDivElement | null>(null);
async function loadUser() { async function loadUser() {
try { try {
@ -133,7 +141,10 @@ export default function NavBar() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!user) setAdminMenuOpen(false); if (!user) {
setAdminMenuOpen(false);
setUserMenuOpen(false);
}
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@ -167,15 +178,19 @@ export default function NavBar() {
const showAdminMenu = Boolean(user && (showApprovals || isAdmin)); const showAdminMenu = Boolean(user && (showApprovals || isAdmin));
useEffect(() => { useEffect(() => {
if (!adminMenuOpen) return; if (!userMenuOpen && !adminMenuOpen) return;
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node | null; const target = e.target as Node | null;
if (target && adminMenuRef.current && !adminMenuRef.current.contains(target)) { const insideAdmin = adminMenuRef.current && target && adminMenuRef.current.contains(target);
setAdminMenuOpen(false); const insideUser = userMenuRef.current && target && userMenuRef.current.contains(target);
} if (!insideAdmin) setAdminMenuOpen(false);
if (!insideUser) setUserMenuOpen(false);
}; };
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setAdminMenuOpen(false); if (e.key === 'Escape') {
setAdminMenuOpen(false);
setUserMenuOpen(false);
}
}; };
document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
@ -183,7 +198,7 @@ export default function NavBar() {
document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
}; };
}, [adminMenuOpen]); }, [adminMenuOpen, userMenuOpen]);
return ( return (
<header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -199,18 +214,46 @@ export default function NavBar() {
<nav style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <nav style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{user ? ( {user ? (
<> <>
<span style={{ fontSize: 12, color: '#444', border: '1px solid #ddd', borderRadius: 12, padding: '4px 10px' }}> <div className="nav-admin" ref={userMenuRef}>
{user.email} · {user.role} <button
</span> type="button"
<Link href="/me" className="button secondary"> className="button secondary"
<Icon name="profile" /> {t('navProfile')} aria-haspopup="menu"
</Link> aria-expanded={userMenuOpen}
<Link href="/listings/mine" className="button secondary"> onClick={() => setUserMenuOpen((v) => !v)}
<Icon name="list" /> {t('navMyListings')} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}
</Link> >
<Link href="/listings/new" className="button secondary"> <div style={{ display: 'grid', gap: 2, textAlign: 'left' }}>
<Icon name="plus" /> {t('navNewListing')} <span style={{ fontWeight: 600 }}>{user.email}</span>
</Link> <span style={{ fontSize: 12, opacity: 0.7 }}>{user.role}</span>
</div>
<Icon name="chevron-down" />
</button>
{userMenuOpen ? (
<div className="nav-admin-menu" role="menu" aria-label={t('navProfile')}>
<Link href="/me" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}>
<Icon name="profile" /> {t('navProfile')}
</Link>
<Link href="/listings/mine" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}>
<Icon name="list" /> {t('navMyListings')}
</Link>
<Link href="/listings/new" className="nav-admin-item button secondary" role="menuitem" onClick={() => setUserMenuOpen(false)}>
<Icon name="plus" /> {t('navNewListing')}
</Link>
<button
type="button"
className="nav-admin-item button secondary"
role="menuitem"
onClick={() => {
setUserMenuOpen(false);
logout();
}}
>
<Icon name="logout" /> {t('navLogout')}
</button>
</div>
) : null}
</div>
{showAdminMenu ? ( {showAdminMenu ? (
<div className="nav-admin" ref={adminMenuRef}> <div className="nav-admin" ref={adminMenuRef}>
<button <button
@ -219,6 +262,7 @@ export default function NavBar() {
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={adminMenuOpen} aria-expanded={adminMenuOpen}
onClick={() => setAdminMenuOpen((v) => !v)} onClick={() => setAdminMenuOpen((v) => !v)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
> >
<Icon name="admin" /> {t('navAdmin')} <Icon name="admin" /> {t('navAdmin')}
{showApprovals && pendingCount > 0 ? ( {showApprovals && pendingCount > 0 ? (
@ -226,6 +270,7 @@ export default function NavBar() {
{t('approvalsBadge', { count: pendingCount })} {t('approvalsBadge', { count: pendingCount })}
</span> </span>
) : null} ) : null}
<Icon name="chevron-down" />
</button> </button>
{adminMenuOpen ? ( {adminMenuOpen ? (
<div className="nav-admin-menu" role="menu" aria-label={t('navAdmin')}> <div className="nav-admin-menu" role="menu" aria-label={t('navAdmin')}>
@ -258,9 +303,6 @@ export default function NavBar() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
<button className="button secondary" onClick={logout}>
<Icon name="logout" /> {t('navLogout')}
</button>
</> </>
) : ( ) : (
<> <>