Rewrote UserMenu to use CSS variables and portal rendering instead of hardcoded Tailwind classes. Added localized labels for all 5 languages. v1.22.2
182 lines
8.4 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|