Header.tsx

  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 { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
 10import { Link, useMatch, NavLink } from "react-router";
 11
 12import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 13import { Button } from "@/components/ui/button";
 14import { useAuth } from "@/lib/auth";
 15import { useTheme } from "@/lib/theme";
 16import { cn } from "@/lib/utils";
 17
 18// SignOutButton sends a POST to /auth/logout and reloads the page.
 19// A full reload is the simplest way to reset all Apollo cache + React state.
 20function SignOutButton() {
 21  function handleSignOut() {
 22    void fetch("/auth/logout", { method: "POST", credentials: "include" }).finally(() =>
 23      window.location.assign("/"),
 24    );
 25  }
 26  return (
 27    <Button variant="ghost" size="sm" onClick={handleSignOut} title="Sign out">
 28      <LogOut className="size-4" />
 29    </Button>
 30  );
 31}
 32
 33export function Header() {
 34  const { user, mode, loginProviders } = useAuth();
 35  const { theme, toggle } = useTheme();
 36
 37  // Detect if we're inside a /:repo route and grab the slug.
 38  // useMatch works from any component in the tree, unlike useParams which is
 39  // scoped to the nearest Route element.
 40  const repoMatch = useMatch({ path: "/:repo/*", end: false });
 41  const repo = repoMatch?.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="sticky top-0 z-50 border-b border-border bg-background/95 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="flex items-center gap-2 font-semibold text-foreground">
 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 && (
 57          <nav className="flex items-center gap-1">
 58            <NavLink
 59              to={`/${effectiveRepo}`}
 60              end
 61              className={({ isActive }) =>
 62                cn(
 63                  "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
 64                  isActive
 65                    ? "bg-accent text-accent-foreground"
 66                    : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
 67                )
 68              }
 69            >
 70              Code
 71            </NavLink>
 72            <NavLink
 73              to={`/${effectiveRepo}/issues`}
 74              className={({ isActive }) =>
 75                cn(
 76                  "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
 77                  isActive
 78                    ? "bg-accent text-accent-foreground"
 79                    : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
 80                )
 81              }
 82            >
 83              Issues
 84            </NavLink>
 85          </nav>
 86        )}
 87
 88        <div className="ml-auto flex items-center gap-2">
 89          {mode === "readonly" && <span className="text-xs text-muted-foreground">Read only</span>}
 90
 91          <Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme">
 92            {theme === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
 93          </Button>
 94
 95          {/* External mode: show sign-in buttons when logged out */}
 96          {mode === "external" &&
 97            !user &&
 98            loginProviders.map((p) => (
 99              <Button key={p} asChild size="sm">
100                <a href={`/auth/login?provider=${p}`}>
101                  <LogIn className="size-4" />
102                  Sign in with {providerLabel(p)}
103                </a>
104              </Button>
105            ))}
106
107          {user && effectiveRepo && (
108            <>
109              <Button asChild size="sm">
110                <Link to={`/${effectiveRepo}/issues/new`}>
111                  <Plus className="size-4" />
112                  New issue
113                </Link>
114              </Button>
115              <Link to={`/${effectiveRepo}/user/${user.humanId}`}>
116                <Avatar className="size-7">
117                  <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
118                  <AvatarFallback className="text-xs">
119                    {user.displayName.slice(0, 2).toUpperCase()}
120                  </AvatarFallback>
121                </Avatar>
122              </Link>
123            </>
124          )}
125
126          {/* Sign out only shown in external mode when logged in */}
127          {mode === "external" && user && <SignOutButton />}
128        </div>
129      </div>
130    </header>
131  );
132}
133
134function providerLabel(name: string): string {
135  const labels: Record<string, string> = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" };
136  return labels[name] ?? name;
137}