Skip to content
File

Blob: src/client/components/header.tsx

typescript161 lines
1import { useCallback, useRef, useState } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { Pilcrow, LogOut, User as UserIcon, Maximize2, Minimize2, Menu, Inbox } from "lucide-react";
4import { Avatar } from "@/client/components/ui/avatar";
5import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
6import { useAuthStore, selectHasLocalSession } from "@/client/stores/auth-store";
7import { useWorkspaceDirectoryStore, selectFirstMemberSlug } from "@/client/stores/workspace-directory";
8import { useWorkspaceNavigationStore, selectLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation";
9import { useAuth } from "@/client/hooks/use-auth";
10import { useSharedInboxNavigation } from "@/client/hooks/use-shared-inbox-navigation";
11import { useScrollVisibility } from "@/client/hooks/use-scroll-visibility";
12 
13const MENU_ITEM_CLASS =
14 "group flex min-h-8 w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[13px] text-zinc-300 transition-[background-color,color] hover:bg-zinc-700 hover:text-zinc-100 focus-visible:bg-zinc-700 focus-visible:text-zinc-100 focus-visible:outline-none";
15const MENU_ICON_CLASS =
16 "flex w-4 shrink-0 items-center justify-center text-zinc-400 transition-colors group-hover:text-current group-focus-visible:text-current";
17 
18interface HeaderProps {
19 expanded: boolean;
20 onToggleLayout: () => void;
21 onToggleMobileSidebar?: () => void;
22}
23 
24export function Header({ expanded, onToggleLayout, onToggleMobileSidebar }: HeaderProps) {
25 const hasLocalSession = useAuthStore(selectHasLocalSession);
26 const user = useAuthStore((s) => s.user);
27 const { logout } = useAuth();
28 const navigate = useNavigate();
29 const visible = useScrollVisibility("main-content");
30 const [menuOpen, setMenuOpen] = useState(false);
31 const menuTriggerRef = useRef<HTMLButtonElement>(null);
32 
33 const lastVisitedId = useWorkspaceNavigationStore(selectLastVisitedWorkspaceId);
34 const memberSlug = useWorkspaceDirectoryStore((s) => selectFirstMemberSlug(s, lastVisitedId));
35 const homeSlug = hasLocalSession ? memberSlug : null;
36 const { canLeaveSharedInbox, isSharedInbox, toggleSharedInbox } = useSharedInboxNavigation();
37 
38 const closeMenu = useCallback(() => setMenuOpen(false), []);
39 
40 const handleLogout = useCallback(async () => {
41 setMenuOpen(false);
42 await logout();
43 navigate({ to: "/login", search: { redirect: undefined } });
44 }, [logout, navigate]);
45 
46 return (
47 <header
48 className={`relative z-50 shrink-0 border-b border-zinc-800/60 bg-zinc-900/95 backdrop-blur-sm transition-[margin-top] duration-300 ease-out ${visible ? "mt-0" : "-mt-[61px]"}`}
49 >
50 <div className={`flex items-center px-4 py-3 sm:px-6 ${expanded ? "" : "mx-auto max-w-7xl"}`}>
51 {hasLocalSession && onToggleMobileSidebar && (
52 <button
53 onClick={onToggleMobileSidebar}
54 className="mr-2 flex items-center justify-center rounded-md p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300 md:hidden"
55 aria-label="Toggle sidebar"
56 >
57 <Menu className="h-5 w-5" />
58 </button>
59 )}
60 <Link
61 to={homeSlug ? "/$workspaceSlug" : "/"}
62 params={homeSlug ? { workspaceSlug: homeSlug } : undefined}
63 className="group flex items-center gap-3 transition-opacity hover:opacity-80"
64 >
65 <span className="inline-grid h-9 w-9 place-items-center">
66 <Pilcrow
67 className="h-6 w-6 text-accent-400 transition-transform duration-200 group-hover:-rotate-6"
68 strokeWidth={2}
69 aria-hidden="true"
70 />
71 </span>
72 <span className="hidden sm:block">
73 <strong className="block text-sm font-semibold text-zinc-100">bland</strong>
74 <small className="block text-xs text-zinc-400">Docs on Cloudflare</small>
75 </span>
76 </Link>
77
78 <div className="flex-1" />
79
80 <div className="flex items-center gap-2">
81 {hasLocalSession && (
82 <button
83 onClick={onToggleLayout}
84 className="hidden items-center justify-center rounded-md p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300 lg:flex"
85 aria-label={expanded ? "Center layout" : "Expand layout"}
86 title={expanded ? "Center layout" : "Expand layout"}
87 >
88 {expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
89 </button>
90 )}
91 {hasLocalSession && (
92 <button
93 onClick={toggleSharedInbox}
94 className={`flex items-center justify-center rounded-md p-1.5 transition-colors ${
95 isSharedInbox
96 ? "bg-accent-500/10 text-accent-400"
97 : "text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300"
98 }`}
99 aria-label={isSharedInbox && canLeaveSharedInbox ? "Back to previous view" : "Shared with me"}
100 title={isSharedInbox && canLeaveSharedInbox ? "Back to previous view" : "Shared with me"}
101 >
102 <Inbox className="h-4 w-4" />
103 </button>
104 )}
105
106 {hasLocalSession && (
107 <>
108 <button
109 ref={menuTriggerRef}
110 onClick={() => setMenuOpen((o) => !o)}
111 className="flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 transition-colors hover:border-zinc-600"
112 aria-label="User menu"
113 aria-expanded={menuOpen}
114 aria-haspopup="menu"
115 >
116 {user?.name ? (
117 <Avatar
118 name={user.name}
119 avatarUrl={user.avatar_url}
120 className="h-full w-full text-sm hover:text-zinc-100"
121 />
122 ) : (
123 <UserIcon className="h-4 w-4 text-zinc-300" />
124 )}
125 </button>
126 {menuOpen && (
127 <DropdownPortal
128 triggerRef={menuTriggerRef}
129 width={208}
130 className="p-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
131 onClose={closeMenu}
132 >
133 <div role="menu" aria-label="User menu">
134 <div className="px-2 py-1.5">
135 <p className="truncate text-[13px] font-medium text-zinc-200">{user?.name}</p>
136 <p className="truncate text-xs text-zinc-400">{user?.email}</p>
137 </div>
138 <div className="my-1 h-px bg-zinc-800" role="separator" />
139 <Link to="/profile" onClick={closeMenu} role="menuitem" className={MENU_ITEM_CLASS}>
140 <span className={MENU_ICON_CLASS}>
141 <UserIcon className="h-3.5 w-3.5" />
142 </span>
143 <span className="flex-1">Profile</span>
144 </Link>
145 <button onClick={handleLogout} role="menuitem" className={MENU_ITEM_CLASS}>
146 <span className={MENU_ICON_CLASS}>
147 <LogOut className="h-3.5 w-3.5" />
148 </span>
149 <span className="flex-1">Sign out</span>
150 </button>
151 </div>
152 </DropdownPortal>
153 )}
154 </>
155 )}
156 </div>
157 </div>
158 </header>
159 );
160}