1// Sticky top navigation bar. Adapts based on whether we're on the repo picker
2// page (root) or inside a specific repo:
3// - Root: shows logo only, no Code/Issues links
4// - Repo: shows Code + Issues nav links scoped to the current repo slug
5//
6// In external mode, shows a "Sign in" button when logged out and a sign-out
7// action when logged in.
8
9import { Link, useParams, useRouterState } from "@tanstack/react-router";
10import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
11
12import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
13import { Button } from "@/components/ui/button";
14import { ButtonLink } from "@/components/ui/button-link";
15import { useAuth } from "@/lib/auth";
16import { useTheme } from "@/lib/theme";
17import { cn } from "@/lib/utils";
18
19// SignOutButton sends a POST to /auth/logout and reloads the page.
20// A full reload is the simplest way to reset all Apollo cache + React state.
21function handleSignOut() {
22 void fetch("/auth/logout", { method: "POST", credentials: "include" }).finally(() =>
23 window.location.assign("/"),
24 );
25}
26
27function SignOutButton() {
28 return (
29 <Button variant="ghost" size="sm" onClick={handleSignOut} title="Sign out">
30 <LogOut className="size-4" />
31 </Button>
32 );
33}
34
35export function Header() {
36 const { user, mode, loginProviders } = useAuth();
37 const { theme, toggle } = useTheme();
38
39 // Detect if we're inside a /$repo route and grab the slug.
40 const params = useParams({ strict: false });
41 const repo = params.repo ?? null;
42
43 // Don't show repo nav on the /auth/* pages.
44 const effectiveRepo = repo === "auth" ? null : repo;
45
46 return (
47 <header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
48 <div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
49 {/* Logo always goes to the repo picker root */}
50 <Link to="/" className="text-foreground flex items-center gap-2 font-semibold">
51 <Bug className="size-4" />
52 <span>git-bug</span>
53 </Link>
54
55 {/* Repo-scoped nav links — only shown when inside a repo */}
56 {effectiveRepo && <RepoNav repo={effectiveRepo} />}
57
58 <div className="ml-auto flex items-center gap-2">
59 {mode === "readonly" && <span className="text-muted-foreground text-xs">Read only</span>}
60
61 <Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme">
62 {theme === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
63 </Button>
64
65 {/* External mode: show sign-in buttons when logged out */}
66 {mode === "external" &&
67 !user &&
68 loginProviders.map((p) => (
69 <Button key={p} asChild size="sm">
70 <a href={`/auth/login?provider=${p}`}>
71 <LogIn className="size-4" />
72 Sign in with {providerLabel(p)}
73 </a>
74 </Button>
75 ))}
76
77 {user && effectiveRepo && (
78 <>
79 <ButtonLink to="/$repo/issues/new" params={{ repo: effectiveRepo }} size="sm">
80 <Plus className="size-4" />
81 New issue
82 </ButtonLink>
83 <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
84 <Avatar className="size-7">
85 <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
86 <AvatarFallback className="text-xs">
87 {user.displayName.slice(0, 2).toUpperCase()}
88 </AvatarFallback>
89 </Avatar>
90 </Link>
91 </>
92 )}
93
94 {/* Sign out only shown in external mode when logged in */}
95 {mode === "external" && user && <SignOutButton />}
96 </div>
97 </div>
98 </header>
99 );
100}
101
102const navLinkBase = "rounded-md px-3 py-1.5 text-sm font-medium transition-colors";
103const navLinkActive = "bg-accent text-accent-foreground";
104const navLinkInactive = "text-muted-foreground hover:bg-accent hover:text-accent-foreground";
105
106function RepoNav({ repo }: { repo: string }) {
107 // Determine which section is active from the matched route IDs.
108 // The _code layout match means we're in the code browser; _issues means issues.
109 const matchedIds = useRouterState({
110 select: (s) => s.matches.map((m) => m.routeId),
111 });
112 const isCodeActive = matchedIds.some((id) => id.includes("/_code"));
113 const isIssuesActive = matchedIds.some((id) => id.includes("/_issues"));
114
115 return (
116 <nav className="flex items-center gap-1">
117 <Link
118 to="/$repo"
119 params={{ repo }}
120 className={cn(navLinkBase, isCodeActive ? navLinkActive : navLinkInactive)}
121 >
122 Code
123 </Link>
124 <Link
125 to="/$repo/issues"
126 params={{ repo }}
127 search={{ q: "status:open", after: "" }}
128 className={cn(navLinkBase, isIssuesActive ? navLinkActive : navLinkInactive)}
129 >
130 Issues
131 </Link>
132 </nav>
133 );
134}
135
136function providerLabel(name: string): string {
137 const labels: Record<string, string> = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" };
138 return labels[name] ?? name;
139}