A personal media tracker built on the AT Protocol opnshelf.xyz
at main 454 lines 11 kB view raw
1import { authControllerLogoutMutation, type UserDto } from "@opnshelf/api"; 2import { useMutation, useQueryClient } from "@tanstack/react-query"; 3import { Link, useLocation, useNavigate } from "@tanstack/react-router"; 4import { 5 BookOpen, 6 CalendarDays, 7 ChevronDown, 8 Home, 9 List, 10 LogIn, 11 LogOut, 12 Search, 13 Settings, 14 Tv, 15 User, 16} from "lucide-react"; 17import { useState } from "react"; 18import { useTheme } from "@/components/theme-provider"; 19import { M3Button } from "@/components/ui/m3-button"; 20import { 21 Popover, 22 PopoverContent, 23 PopoverTrigger, 24} from "@/components/ui/popover"; 25import { publishSignedOutAuthState } from "@/lib/auth-cache"; 26import { cn } from "@/lib/utils"; 27import { 28 type GlobalNavItem, 29 getCalendarRoute, 30 getHomeRoute, 31 getListsRoute, 32 getMyShelfRoute, 33 getSearchRoute, 34 getSettingsRoute, 35 getSignedInPrimaryNav, 36 getSignedOutPrimaryNav, 37 getUpNextRoute, 38 isGlobalNavItemActive, 39} from "@/lib/web-navigation"; 40 41interface HeaderProps { 42 user: UserDto | null | undefined; 43 isAuthLoading: boolean; 44} 45 46type NavLinkTarget = 47 | ReturnType<typeof getHomeRoute> 48 | ReturnType<typeof getSearchRoute> 49 | ReturnType<typeof getMyShelfRoute> 50 | ReturnType<typeof getUpNextRoute> 51 | ReturnType<typeof getListsRoute> 52 | ReturnType<typeof getCalendarRoute> 53 | ReturnType<typeof getSettingsRoute>; 54 55const navIcons = { 56 home: Home, 57 search: Search, 58 "my-shelf": BookOpen, 59} satisfies Record<GlobalNavItem["id"], typeof Home>; 60 61export default function Header({ user, isAuthLoading }: HeaderProps) { 62 const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); 63 const queryClient = useQueryClient(); 64 const navigate = useNavigate(); 65 const location = useLocation(); 66 const { seedColor } = useTheme(); 67 68 const primaryNav = user ? getSignedInPrimaryNav() : getSignedOutPrimaryNav(); 69 const logoutMutation = useMutation({ 70 mutationKey: ["auth", "logout"], 71 ...authControllerLogoutMutation(), 72 onSuccess: async () => { 73 await publishSignedOutAuthState(queryClient); 74 navigate({ to: "/" }); 75 }, 76 }); 77 78 const handleLogout = async () => { 79 setIsAccountMenuOpen(false); 80 await logoutMutation.mutateAsync({}); 81 }; 82 83 return ( 84 <header 85 className="sticky top-0 z-40 border-b" 86 style={{ 87 backgroundColor: "var(--md-sys-color-surface)", 88 borderColor: "var(--md-sys-color-outline-variant)", 89 boxShadow: 90 "0 18px 40px color-mix(in srgb, var(--md-sys-color-scrim) 28%, transparent), inset 0 -1px 0 color-mix(in srgb, var(--md-sys-color-on-surface) 2%, transparent)", 91 }} 92 > 93 <div 94 className="absolute inset-x-0 top-0 h-px" 95 style={{ 96 background: `linear-gradient(90deg, transparent, ${seedColor}, transparent)`, 97 opacity: 0.45, 98 }} 99 /> 100 101 <div className="container mx-auto flex h-16 max-w-7xl items-center justify-between gap-3 px-4 md:h-18"> 102 <Brand seedColor={seedColor} /> 103 104 <nav className="hidden min-w-0 flex-1 items-center justify-center gap-2 md:flex"> 105 {primaryNav.map((item) => ( 106 <PrimaryNavLink 107 key={item.id} 108 item={item} 109 currentPath={location.pathname} 110 currentUserHandle={user?.handle} 111 seedColor={seedColor} 112 /> 113 ))} 114 </nav> 115 116 <div className="flex items-center gap-2"> 117 {isAuthLoading ? ( 118 <AuthActionsSkeleton /> 119 ) : user ? ( 120 <AccountMenu 121 user={user} 122 seedColor={seedColor} 123 isOpen={isAccountMenuOpen} 124 onOpenChange={setIsAccountMenuOpen} 125 onLogout={handleLogout} 126 isLoggingOut={logoutMutation.isPending} 127 /> 128 ) : ( 129 <SignedOutActions /> 130 )} 131 </div> 132 </div> 133 </header> 134 ); 135} 136 137function Brand({ seedColor }: { seedColor: string }) { 138 return ( 139 <Link to="/" className="group flex items-center gap-3"> 140 <div 141 className="flex size-10 items-center justify-center rounded-lg border transition-transform duration-300 group-hover:scale-[1.04]" 142 style={{ 143 backgroundColor: "var(--md-sys-color-surface-container-high)", 144 borderColor: "var(--md-sys-color-outline-variant)", 145 boxShadow: `0 0 0 1px ${seedColor}24 inset`, 146 }} 147 > 148 <img src="/icon.png" alt="OpnShelf" className="size-7 rounded-xl" /> 149 </div> 150 151 <div className="min-w-0"> 152 <div className="md-title-large leading-none">OpnShelf</div> 153 </div> 154 </Link> 155 ); 156} 157 158function SignedOutActions() { 159 return ( 160 <> 161 <Link 162 {...getSearchRoute()} 163 className="inline-flex size-10 items-center justify-center rounded-full border transition-colors md:hidden" 164 style={{ 165 backgroundColor: "var(--md-sys-color-surface-container)", 166 borderColor: "var(--md-sys-color-outline-variant)", 167 color: "var(--md-sys-color-on-surface)", 168 }} 169 aria-label="Search" 170 > 171 <Search className="size-4" /> 172 </Link> 173 174 <M3Button 175 variant="filled" 176 size="sm" 177 asChild 178 className="rounded-full px-4" 179 > 180 <Link to="/login"> 181 <LogIn className="size-4" /> 182 <span className="hidden sm:inline">Sign in</span> 183 </Link> 184 </M3Button> 185 </> 186 ); 187} 188 189function AuthActionsSkeleton() { 190 return ( 191 <div className="flex items-center gap-2"> 192 <div 193 className="hidden h-10 w-28 animate-pulse rounded-full md:block" 194 style={{ 195 backgroundColor: "var(--md-sys-color-surface-container-high)", 196 }} 197 /> 198 <div 199 className="size-10 animate-pulse rounded-full" 200 style={{ 201 backgroundColor: "var(--md-sys-color-surface-container-high)", 202 }} 203 /> 204 </div> 205 ); 206} 207 208function PrimaryNavLink({ 209 item, 210 currentPath, 211 currentUserHandle, 212 seedColor, 213}: { 214 item: GlobalNavItem; 215 currentPath: string; 216 currentUserHandle?: string; 217 seedColor: string; 218}) { 219 const Icon = navIcons[item.id]; 220 const isActive = isGlobalNavItemActive( 221 item.id, 222 currentPath, 223 currentUserHandle, 224 ); 225 const target = getNavTarget(item.id, currentUserHandle); 226 227 if (!target) { 228 return null; 229 } 230 231 return ( 232 <Link 233 {...target} 234 className={cn( 235 "inline-flex items-center gap-2 rounded-full border px-4 py-2 transition-all duration-200", 236 "hover:-translate-y-0.5 hover:bg-(--md-sys-color-surface-container-high) hover:text-(--md-sys-color-on-surface)", 237 )} 238 style={ 239 isActive 240 ? { 241 backgroundColor: `${seedColor}22`, 242 borderColor: `${seedColor}55`, 243 color: seedColor, 244 boxShadow: `0 10px 24px ${seedColor}14`, 245 } 246 : { 247 backgroundColor: "var(--md-sys-color-surface-container-low)", 248 borderColor: "var(--md-sys-color-outline-variant)", 249 color: "var(--md-sys-color-on-surface-variant)", 250 } 251 } 252 > 253 <Icon className="size-4" /> 254 <span className="md-label-large">{item.label}</span> 255 </Link> 256 ); 257} 258 259function AccountMenu({ 260 user, 261 seedColor, 262 isOpen, 263 onOpenChange, 264 onLogout, 265 isLoggingOut, 266}: { 267 user: UserDto; 268 seedColor: string; 269 isOpen: boolean; 270 onOpenChange: (open: boolean) => void; 271 onLogout: () => Promise<void>; 272 isLoggingOut: boolean; 273}) { 274 const displayName = user.displayName 275 ? String(user.displayName) 276 : `@${user.handle}`; 277 const avatar = user.avatar ? String(user.avatar) : null; 278 279 return ( 280 <Popover open={isOpen} onOpenChange={onOpenChange}> 281 <PopoverTrigger asChild> 282 <button 283 type="button" 284 className="inline-flex size-10 items-center justify-center rounded-full border transition-transform duration-200 hover:scale-[1.02] md:size-auto md:gap-2 md:px-2.5" 285 style={{ 286 backgroundColor: "var(--md-sys-color-surface-container)", 287 borderColor: "var(--md-sys-color-outline-variant)", 288 color: "var(--md-sys-color-on-surface)", 289 }} 290 aria-label="Open account menu" 291 > 292 <Avatar user={user} seedColor={seedColor} className="size-8" /> 293 <ChevronDown className="hidden size-4 md:block" /> 294 </button> 295 </PopoverTrigger> 296 297 <PopoverContent 298 align="end" 299 sideOffset={10} 300 className="w-[20rem] rounded-xl border p-2" 301 style={{ 302 backgroundColor: "var(--md-sys-color-surface-container-high)", 303 borderColor: "var(--md-sys-color-outline-variant)", 304 }} 305 > 306 <div 307 className="mb-2 flex items-center gap-3 rounded-lg border px-3 py-3" 308 style={{ 309 backgroundColor: "var(--md-sys-color-surface-container-low)", 310 borderColor: "var(--md-sys-color-outline-variant)", 311 }} 312 > 313 {avatar ? ( 314 <img 315 src={avatar} 316 alt={displayName} 317 className="size-11 rounded-full object-cover" 318 /> 319 ) : ( 320 <Avatar user={user} seedColor={seedColor} className="size-11" /> 321 )} 322 <div className="min-w-0"> 323 <p className="truncate md-title-medium">{displayName}</p> 324 <p 325 className="truncate md-body-small" 326 style={{ color: "var(--md-sys-color-on-surface-variant)" }} 327 > 328 @{user.handle} 329 </p> 330 </div> 331 </div> 332 333 <div className="space-y-1"> 334 <MenuLink 335 target={getMyShelfRoute(user.handle)} 336 icon={User} 337 label="My Profile" 338 onSelect={() => onOpenChange(false)} 339 /> 340 <MenuLink 341 target={getUpNextRoute(user.handle)} 342 icon={Tv} 343 label="Up Next" 344 onSelect={() => onOpenChange(false)} 345 /> 346 <MenuLink 347 target={getListsRoute(user.handle)} 348 icon={List} 349 label="Lists" 350 onSelect={() => onOpenChange(false)} 351 /> 352 <MenuLink 353 target={getCalendarRoute(user.handle)} 354 icon={CalendarDays} 355 label="Calendar" 356 onSelect={() => onOpenChange(false)} 357 /> 358 <MenuLink 359 target={getSettingsRoute(user.handle)} 360 icon={Settings} 361 label="Settings" 362 onSelect={() => onOpenChange(false)} 363 /> 364 <button 365 type="button" 366 onClick={onLogout} 367 disabled={isLoggingOut} 368 className="flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-(--md-sys-color-surface-container-low) disabled:opacity-60" 369 style={{ color: "var(--md-sys-color-on-surface)" }} 370 > 371 <LogOut className="size-4" /> 372 <span className="md-label-large">Sign out</span> 373 </button> 374 </div> 375 </PopoverContent> 376 </Popover> 377 ); 378} 379 380function MenuLink({ 381 target, 382 icon: Icon, 383 label, 384 onSelect, 385}: { 386 target: NavLinkTarget; 387 icon: typeof User; 388 label: string; 389 onSelect: () => void; 390}) { 391 return ( 392 <Link 393 {...target} 394 onClick={onSelect} 395 className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-(--md-sys-color-surface-container-low)" 396 style={{ color: "var(--md-sys-color-on-surface)" }} 397 > 398 <Icon className="size-4" /> 399 <span className="md-label-large">{label}</span> 400 </Link> 401 ); 402} 403 404function Avatar({ 405 user, 406 seedColor, 407 className, 408}: { 409 user: UserDto; 410 seedColor: string; 411 className?: string; 412}) { 413 if (user.avatar) { 414 return ( 415 <img 416 src={String(user.avatar)} 417 alt={user.displayName ? String(user.displayName) : user.handle} 418 className={cn("rounded-full object-cover", className)} 419 /> 420 ); 421 } 422 423 return ( 424 <div 425 className={cn( 426 "flex items-center justify-center rounded-full text-(--md-sys-color-on-primary)", 427 className, 428 )} 429 style={{ backgroundColor: seedColor }} 430 > 431 {user.displayName ? ( 432 <span className="text-sm font-bold uppercase"> 433 {String(user.displayName).charAt(0)} 434 </span> 435 ) : ( 436 <User className="size-4" /> 437 )} 438 </div> 439 ); 440} 441 442function getNavTarget( 443 itemId: GlobalNavItem["id"], 444 currentUserHandle?: string, 445): NavLinkTarget | null { 446 switch (itemId) { 447 case "home": 448 return getHomeRoute(); 449 case "search": 450 return getSearchRoute(); 451 case "my-shelf": 452 return currentUserHandle ? getMyShelfRoute(currentUserHandle) : null; 453 } 454}