My-Weekly-ToDo-List/src/components/UserMenu.tsx
mARTin 9512c6ceda fix: UserMenu dropdown invisible in dark mode and clipped by overflow
Rewrote UserMenu to use CSS variables and portal rendering instead of
hardcoded Tailwind classes. Added localized labels for all 5 languages.

v1.22.2
2026-03-10 15:35:39 +01:00

182 lines
8.4 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { signOut } from 'next-auth/react';
interface UserMenuProps {
userEmail?: string | null;
onOpenSettings: () => void;
trigger?: React.ReactNode;
language?: string;
}
export default function UserMenu({ userEmail, onOpenSettings, trigger, language = 'en' }: UserMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ top: number; right: number } | null>(null);
const labels = {
en: { signedIn: 'Signed in as', settings: 'Settings', signOut: 'Sign Out' },
de: { signedIn: 'Angemeldet als', settings: 'Einstellungen', signOut: 'Abmelden' },
fr: { signedIn: 'Connecté en tant que', settings: 'Paramètres', signOut: 'Se déconnecter' },
es: { signedIn: 'Conectado como', settings: 'Ajustes', signOut: 'Cerrar sesión' },
it: { signedIn: 'Connesso come', settings: 'Impostazioni', signOut: 'Esci' },
}[language] || { signedIn: 'Signed in as', settings: 'Settings', signOut: 'Sign Out' };
const updatePosition = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
}, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current && !menuRef.current.contains(event.target as Node) &&
triggerRef.current && !triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const menuContent = isOpen && position ? createPortal(
<div
ref={menuRef}
style={{
position: 'fixed',
top: position.top,
right: position.right,
width: '14rem',
backgroundColor: 'var(--weekly-bg, white)',
border: '1px solid var(--weekly-border, #e5e7eb)',
borderRadius: '0.5rem',
boxShadow: '0 20px 25px -5px rgba(0,0,0,0.15), 0 10px 10px -5px rgba(0,0,0,0.08)',
zIndex: 9999,
animation: 'userMenuFadeIn 0.15s ease-out',
overflow: 'hidden',
}}
>
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--weekly-border, #e5e7eb)' }}>
<p style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--weekly-text, #374151)', margin: 0 }}>{labels.signedIn}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light, #9ca3af)', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{userEmail || 'User'}</p>
</div>
<button
onClick={() => { onOpenSettings(); setIsOpen(false); }}
style={{
width: '100%',
textAlign: 'left',
padding: '8px 14px',
fontSize: '0.85rem',
color: 'var(--weekly-text, #374151)',
background: 'none',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--weekly-hover-bg, #f3f4f6)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
{labels.settings}
</button>
<div style={{ borderTop: '1px solid var(--weekly-border, #e5e7eb)', margin: '2px 0' }}></div>
<button
onClick={() => signOut()}
style={{
width: '100%',
textAlign: 'left',
padding: '8px 14px',
fontSize: '0.85rem',
color: '#ef4444',
background: 'none',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(239,68,68,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7 }}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
{labels.signOut}
</button>
<style>{`
@keyframes userMenuFadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>,
document.body
) : null;
return (
<>
<div ref={triggerRef}>
{trigger ? (
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
) : (
<button
onClick={() => setIsOpen(!isOpen)}
style={{
width: '2rem',
height: '2rem',
borderRadius: '50%',
background: 'var(--weekly-hover-bg, #e5e7eb)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
cursor: 'pointer',
color: 'var(--weekly-text-light, #6b7280)',
transition: 'background 0.15s',
}}
title="User Menu"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</button>
)}
</div>
{menuContent}
</>
);
}