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