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, 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}