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
This commit is contained in:
parent
ef61ec3c20
commit
9512c6ceda
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.22.1",
|
"version": "1.22.2",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,84 +1,181 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserMenu({ userEmail, onOpenSettings, trigger }: UserMenuProps) {
|
export default function UserMenu({ userEmail, onOpenSettings, trigger, language = 'en' }: UserMenuProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
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) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (
|
||||||
|
menuRef.current && !menuRef.current.contains(event.target as Node) &&
|
||||||
|
triggerRef.current && !triggerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
const menuContent = isOpen && position ? createPortal(
|
||||||
<div className="relative" ref={menuRef}>
|
<div
|
||||||
{trigger ? (
|
ref={menuRef}
|
||||||
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
|
style={{
|
||||||
) : (
|
position: 'fixed',
|
||||||
<button
|
top: position.top,
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
right: position.right,
|
||||||
className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center hover:bg-gray-300 transition-colors"
|
width: '14rem',
|
||||||
title="User Menu"
|
backgroundColor: 'var(--weekly-bg, white)',
|
||||||
>
|
border: '1px solid var(--weekly-border, #e5e7eb)',
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-600">
|
borderRadius: '0.5rem',
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
boxShadow: '0 20px 25px -5px rgba(0,0,0,0.15), 0 10px 10px -5px rgba(0,0,0,0.08)',
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
zIndex: 9999,
|
||||||
</svg>
|
animation: 'userMenuFadeIn 0.15s ease-out',
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
{isOpen && (
|
<button
|
||||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-xl border border-gray-100 py-1 z-50 animate-in fade-in slide-in-from-top-1 duration-200">
|
onClick={() => { onOpenSettings(); setIsOpen(false); }}
|
||||||
<div className="px-4 py-2 border-b border-gray-100">
|
style={{
|
||||||
<p className="text-sm font-medium text-gray-900">Signed in as</p>
|
width: '100%',
|
||||||
<p className="text-xs text-gray-500 truncate">{userEmail || 'User'}</p>
|
textAlign: 'left',
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<button
|
<div style={{ borderTop: '1px solid var(--weekly-border, #e5e7eb)', margin: '2px 0' }}></div>
|
||||||
onClick={() => { onOpenSettings(); setIsOpen(false); }}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
|
|
||||||
<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>
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="border-t border-gray-100 my-1"></div>
|
<style>{`
|
||||||
|
@keyframes userMenuFadeIn {
|
||||||
<button
|
|
||||||
onClick={() => signOut()}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-red-400">
|
|
||||||
<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>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<style jsx>{`
|
|
||||||
.animate-in { animation: fadeIn 0.15s ease-out; }
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5140,6 +5140,7 @@ export default function WeeklyView() {
|
|||||||
<UserMenu
|
<UserMenu
|
||||||
userEmail={session?.user?.email}
|
userEmail={session?.user?.email}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
language={profile.language}
|
||||||
trigger={
|
trigger={
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user