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 { Link, useParams } 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, NavLink } from "@/components/ui/button-link";
 15import { useAuth } from "@/lib/auth";
 16import { useTheme } from "@/lib/theme";
 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 handleSignOut() {
 21  void fetch("/auth/logout", { method: "POST", credentials: "include" }).finally(() =>
 22    window.location.assign("/"),
 23  );
 24}
 25
 26function SignOutButton() {
 27  return (
 28    <Button variant="ghost" size="sm" onClick={handleSignOut} title="Sign out">
 29      <LogOut className="size-4" />
 30    </Button>
 31  );
 32}
 33
 34export function Header() {
 35  const { user, mode, loginProviders } = useAuth();
 36  const { theme, toggle } = useTheme();
 37
 38  // Detect if we're inside a /$repo route and grab the slug.
 39  const params = useParams({ strict: false });
 40  const repo = params.repo ?? null;
 41
 42  // Don't show repo nav on the /auth/* pages.
 43  const effectiveRepo = repo === "auth" ? null : repo;
 44
 45  return (
 46    <header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
 47      <div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
 48        {/* Logo always goes to the repo picker root */}
 49        <Link to="/" className="text-foreground flex items-center gap-2 font-semibold">
 50          <Bug className="size-4" />
 51          <span>git-bug</span>
 52        </Link>
 53
 54        {/* Repo-scoped nav links — only shown when inside a repo */}
 55        {effectiveRepo && (
 56          <nav className="flex items-center gap-1">
 57            <NavLink
 58              to="/$repo"
 59              params={{ repo: effectiveRepo }}
 60              search={{ ref: "", path: "", type: "tree" as const }}
 61              activeOptions={{ exact: true }}
 62            >
 63              Code
 64            </NavLink>
 65            <NavLink
 66              to="/$repo/issues"
 67              params={{ repo: effectiveRepo }}
 68              search={{ q: "status:open", after: "" }}
 69            >
 70              Issues
 71            </NavLink>
 72          </nav>
 73        )}
 74
 75        <div className="ml-auto flex items-center gap-2">
 76          {mode === "readonly" && <span className="text-muted-foreground text-xs">Read only</span>}
 77
 78          <Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme">
 79            {theme === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
 80          </Button>
 81
 82          {/* External mode: show sign-in buttons when logged out */}
 83          {mode === "external" &&
 84            !user &&
 85            loginProviders.map((p) => (
 86              <Button key={p} asChild size="sm">
 87                <a href={`/auth/login?provider=${p}`}>
 88                  <LogIn className="size-4" />
 89                  Sign in with {providerLabel(p)}
 90                </a>
 91              </Button>
 92            ))}
 93
 94          {user && effectiveRepo && (
 95            <>
 96              <ButtonLink to="/$repo/issues/new" params={{ repo: effectiveRepo }} size="sm">
 97                <Plus className="size-4" />
 98                New issue
 99              </ButtonLink>
100              <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
101                <Avatar className="size-7">
102                  <AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
103                  <AvatarFallback className="text-xs">
104                    {user.displayName.slice(0, 2).toUpperCase()}
105                  </AvatarFallback>
106                </Avatar>
107              </Link>
108            </>
109          )}
110
111          {/* Sign out only shown in external mode when logged in */}
112          {mode === "external" && user && <SignOutButton />}
113        </div>
114      </div>
115    </header>
116  );
117}
118
119function providerLabel(name: string): string {
120  const labels: Record<string, string> = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" };
121  return labels[name] ?? name;
122}