/** * AccountMenu — enterprise-grade account popover anchored to the sidebar avatar. * * Structure (Claude / Slack / GitHub pattern): * [Avatar] Display Name * email / username * ───────────────────────── * Personalization * Settings * ───────────────────────── * About * ───────────────────────── * Log out * * UX rules: * - Opens upward from the avatar (bottom-left anchor) * - Closes on outside click or Esc * - Keyboard-navigable (arrow keys, Enter) * - Log out: one click, no confirmation, instant redirect * - Identity at top, exit at bottom */ import React, { useEffect, useRef, useCallback } from 'react' import { Settings, LogOut, Palette, Info } from 'lucide-react' import UserAvatar from './UserAvatar' export interface AccountMenuUser { id: string username: string display_name: string email: string avatar_url: string } interface AccountMenuProps { user: AccountMenuUser onClose: () => void onOpenSettings: () => void onOpenProfile: () => void onOpenAbout: () => void onLogout: () => void } export default function AccountMenu({ user, onClose, onOpenSettings, onOpenProfile, onOpenAbout, onLogout, }: AccountMenuProps) { const menuRef = useRef(null) const itemsRef = useRef([]) // Close on outside click useEffect(() => { function handleClickOutside(e: MouseEvent) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose() } } // Delay to avoid the same click that opened the menu from closing it const timer = setTimeout(() => { document.addEventListener('mousedown', handleClickOutside) }, 0) return () => { clearTimeout(timer) document.removeEventListener('mousedown', handleClickOutside) } }, [onClose]) // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const items = itemsRef.current.filter(Boolean) const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement) switch (e.key) { case 'Escape': e.preventDefault() onClose() break case 'ArrowDown': e.preventDefault() if (currentIndex < items.length - 1) { items[currentIndex + 1]?.focus() } else { items[0]?.focus() } break case 'ArrowUp': e.preventDefault() if (currentIndex > 0) { items[currentIndex - 1]?.focus() } else { items[items.length - 1]?.focus() } break case 'Tab': e.preventDefault() onClose() break } }, [onClose]) // Focus first item on mount useEffect(() => { const first = itemsRef.current.find(Boolean) first?.focus() }, []) const setItemRef = (index: number) => (el: HTMLButtonElement | null) => { if (el) itemsRef.current[index] = el } const menuItemClass = [ 'w-full text-left px-3 py-2 text-[13px] rounded-lg', 'flex items-center gap-2.5', 'text-white/80 hover:bg-white/8 hover:text-white', 'focus:bg-white/8 focus:text-white focus:outline-none', 'transition-colors cursor-pointer', ].join(' ') return (
{/* Identity header */}
{user.display_name || user.username}
{user.email || `@${user.username}`}
{/* Main actions */}
{/* About */}
{/* Log out — always last, visually separated */}
) }