A personal media tracker built on the AT Protocol
opnshelf.xyz
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}