Skip to content
File

Blob: src/client/components/user-menu.tsx

typescript124 lines
1import { LogOut, User } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Link, useLocation } from "react-router-dom";
4import { useAuth } from "@/client/auth";
5import { ConfirmDialog } from "@/client/components/ui/confirm-dialog";
6 
7export const UserMenu = () => {
8 const { user, isAuthenticated, isInitializing, signOut } = useAuth();
9 const location = useLocation();
10 const [open, setOpen] = useState(false);
11 const [confirmSignOut, setConfirmSignOut] = useState(false);
12 const menuRef = useRef<HTMLDivElement>(null);
13 
14 useEffect(() => {
15 const handleClickOutside = (event: MouseEvent) => {
16 if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
17 setOpen(false);
18 }
19 };
20 
21 if (open) {
22 document.addEventListener("mousedown", handleClickOutside);
23 }
24 
25 return () => document.removeEventListener("mousedown", handleClickOutside);
26 }, [open]);
27 
28 useEffect(() => {
29 if (!open) return;
30 
31 const handleEscape = (event: KeyboardEvent) => {
32 if (event.key === "Escape") {
33 setOpen(false);
34 }
35 };
36 
37 document.addEventListener("keydown", handleEscape);
38 return () => document.removeEventListener("keydown", handleEscape);
39 }, [open]);
40 
41 if (!isAuthenticated || !user) {
42 if (["/", "/app/login"].includes(location.pathname)) return null;
43 
44 return (
45 <div className="flex items-center gap-3">
46 {isInitializing ? <span className="hidden text-xs text-zinc-500 sm:block">Checking session...</span> : null}
47 <Link
48 to="/app/login"
49 className="inline-flex items-center gap-2 rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-700/60 hover:text-zinc-100"
50 >
51 Sign in
52 </Link>
53 </div>
54 );
55 }
56 
57 return (
58 <div ref={menuRef} className="relative">
59 <button
60 type="button"
61 className="flex items-center gap-2 rounded-xl border border-zinc-800/60 bg-zinc-900/70 px-3 py-1.5 text-sm transition-colors hover:border-zinc-700/60 hover:bg-zinc-800/60"
62 aria-expanded={open}
63 aria-haspopup="true"
64 aria-label={user.displayName}
65 onClick={() => setOpen((prev) => !prev)}
66 >
67 <div className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/15 text-accent-400">
68 <User className="h-3.5 w-3.5" />
69 </div>
70 <span className="hidden text-zinc-200 sm:block">{user.displayName}</span>
71 </button>
72
73 {open ? (
74 <div
75 role="menu"
76 className="absolute right-0 top-full z-50 mt-2 w-56 animate-scale-fade origin-top-right rounded-xl border border-zinc-800/60 bg-zinc-900 p-1.5 shadow-lg shadow-black/40"
77 >
78 <div className="border-b border-zinc-800/60 px-3 py-2.5">
79 <p className="text-sm font-medium text-zinc-200">{user.displayName}</p>
80 <p className="text-xs text-zinc-500">@{user.slug}</p>
81 </div>
82
83 <div className="mt-1.5 space-y-0.5">
84 <Link
85 to="/app/me"
86 role="menuitem"
87 className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-300 transition-colors hover:bg-zinc-800/70 hover:text-zinc-100"
88 onClick={() => setOpen(false)}
89 >
90 <User className="h-4 w-4 text-zinc-500" />
91 Profile & Settings
92 </Link>
93 <button
94 type="button"
95 role="menuitem"
96 className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-300 transition-colors hover:bg-zinc-800/70 hover:text-zinc-100"
97 onClick={() => {
98 setOpen(false);
99 setConfirmSignOut(true);
100 }}
101 >
102 <LogOut className="h-4 w-4 text-zinc-500" />
103 Sign Out
104 </button>
105 </div>
106 </div>
107 ) : null}
108
109 <ConfirmDialog
110 open={confirmSignOut}
111 onConfirm={() => {
112 setConfirmSignOut(false);
113 void signOut();
114 }}
115 onCancel={() => setConfirmSignOut(false)}
116 title="Sign out?"
117 description="Your local session will be cleared."
118 confirmLabel="Sign Out"
119 variant="danger"
120 />
121 </div>
122 );
123};