Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

194 linesCodeBlameRaw
1/**
2 * The public frame's header (lib/chrome.ts, `public`): for a visitor who is
3 * not signed in, on every page that is not about signing in. The logo,
4 * search, Explore and Docs, and signing in or up; on a phone the links fold
5 * into one menu. Someone still confirming their address gets their
6 * account's menu in place of signing in.
7 */
8import { BookOpen, ChevronDown, Compass, CreditCard, LogIn, LogOut, Menu, MonitorDown, Plus, Search, Settings } from "lucide-react";
9import { useState } from "react";
10import { Form, Link, NavLink, useParams, useSubmit } from "react-router";
11
12import type { User } from "@g1t/contracts";
13
14import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
15import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
16import { Logo } from "./logo";
17import { ButtonLink, notACredential } from "./ui";
18import { Avatar } from "./ui/avatar";
19import { Button } from "./ui/button";
20import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
21import { useSignUpCopy } from "../lib/registration";
22
23function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) {
24 return (
25 <NavLink
26 to={to}
27 className={({ isActive }) => `rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${isActive ? "text-fg" : "text-muted"}`}
28 >
29 {children}
30 </NavLink>
31 );
32}
33
34/** What the palette offers someone without the app's sidebar. */
35const PUBLIC_COMMANDS: PaletteCommand[] = [
36 { label: "Features", to: "/features", icon: <BookOpen size={15} /> },
37 { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
38 { label: "Search g1t", hint: "Repositories, code, work items, people", to: "/search", icon: <Search size={15} /> },
39 { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
40 { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
41 { label: "Sign in", to: "/login", icon: <LogIn size={15} /> },
42 { label: "Sign up", to: "/register", icon: <Plus size={15} /> },
43];
44
45/** Sign up: the sign-up page says whether it takes an invite. */
46function SignUpButton() {
47 const copy = useSignUpCopy();
48 return <ButtonLink to="/register">{copy.primary}</ButtonLink>;
49}
50
51export function PublicHeader({ user }: { user: User | null | undefined }) {
52 const submit = useSubmit();
53 const signUp = useSignUpCopy();
54 const [palette, setPalette] = useState(false);
55 usePaletteShortcut(() => setPalette((open) => !open));
56 const params = useParams();
57 const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null;
58 return (
59 <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
60 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
61 <Link to="/" aria-label="g1t home" className="mr-2 flex">
62 <Logo />
63 </Link>
64 <Form action="/search" role="search" className="relative hidden min-w-0 grow sm:block sm:max-w-xs">
65 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
66 <input
67 name="q"
68 {...notACredential()}
69 placeholder="Search g1t"
70 aria-label="Search g1t"
71 className="w-full rounded-md border border-line bg-bg py-1.5 pr-12 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
72 />
73 <PaletteKey className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line" />
74 </Form>
75 <Button type="button" aria-label="Search g1t" onClick={() => setPalette(true)} variant="ghost" size="icon-lg" className="sm:hidden">
76 <Search size={18} />
77 </Button>
78 <CommandPalette
79 open={palette}
80 onOpenChange={setPalette}
81 commands={[
82 ...(user
83 ? PUBLIC_COMMANDS.filter((command) => !["/login", "/register"].includes(command.to ?? ""))
84 : PUBLIC_COMMANDS.map((command) => (command.to === "/register" ? { ...command, label: signUp.primary } : command))),
85 ...THEME_COMMANDS,
86 ]}
87 repo={repo}
88 />
89 <nav aria-label="Main" className="hidden items-center gap-0.5 md:flex">
90 <HeaderLink to="/features">Features</HeaderLink>
91 <HeaderLink to="/explore">Explore</HeaderLink>
92 <HeaderLink to="/download">Download</HeaderLink>
93 <HeaderLink to="https://docs.g1t.sh/">Docs</HeaderLink>
94 </nav>
95 <div className="ml-auto flex items-center gap-2">
96 {/* On a phone the links fold into one menu, so the bar fits. */}
97 <DropdownMenu>
98 <DropdownMenuTrigger asChild>
99 <Button variant="ghost" size="icon-lg" aria-label="Menu" className="md:hidden">
100 <Menu size={18} />
101 </Button>
102 </DropdownMenuTrigger>
103 <DropdownMenuContent align="end" className="w-52">
104 <DropdownMenuItem asChild className="min-h-11">
105 <Link to="/features">
106 <BookOpen />
107 Features
108 </Link>
109 </DropdownMenuItem>
110 <DropdownMenuItem asChild className="min-h-11">
111 <Link to="/explore">
112 <Compass />
113 Explore
114 </Link>
115 </DropdownMenuItem>
116 <DropdownMenuItem asChild className="min-h-11">
117 <Link to="/download">
118 <MonitorDown />
119 Download
120 </Link>
121 </DropdownMenuItem>
122 <DropdownMenuItem asChild className="min-h-11">
123 <Link to="https://docs.g1t.sh/">
124 <BookOpen />
125 Docs
126 </Link>
127 </DropdownMenuItem>
128 <DropdownMenuSeparator />
129 <ThemeMenuSwitch className="px-2" />
130 {!user && (
131 <>
132 <DropdownMenuSeparator />
133 <DropdownMenuItem asChild className="min-h-11">
134 <Link to="/login">
135 <LogIn />
136 Sign in
137 </Link>
138 </DropdownMenuItem>
139 </>
140 )}
141 </DropdownMenuContent>
142 </DropdownMenu>
143 {user ? (
144 <>
145 <DropdownMenu>
146 <DropdownMenuTrigger
147 aria-label="Account menu"
148 className="flex items-center gap-1.5 rounded-md p-1 outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised"
149 >
150 <Avatar name={user.username} image={user.avatar} size={24} />
151 <ChevronDown size={14} className="text-faint" />
152 </DropdownMenuTrigger>
153 <DropdownMenuContent align="end" className="w-56">
154 <DropdownMenuLabel>
155 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
156 </DropdownMenuLabel>
157 <DropdownMenuSeparator />
158 <DropdownMenuItem asChild>
159 <Link to="/settings/profile">
160 <Settings />
161 Settings
162 </Link>
163 </DropdownMenuItem>
164 <DropdownMenuItem asChild>
165 <Link to="https://docs.g1t.sh/quickstart/">
166 <BookOpen />
167 Documentation
168 </Link>
169 </DropdownMenuItem>
170 <DropdownMenuSeparator />
171 <ThemeMenuSwitch className="px-2" />
172 <DropdownMenuSeparator />
173 {/* Submitted from here: the menu closes on select, and a button
174 that has left the page cannot submit a form. */}
175 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
176 <LogOut />
177 Sign out
178 </DropdownMenuItem>
179 </DropdownMenuContent>
180 </DropdownMenu>
181 </>
182 ) : (
183 <>
184 <span className="hidden sm:contents">
185 <HeaderLink to="/login">Sign in</HeaderLink>
186 </span>
187 <SignUpButton />
188 </>
189 )}
190 </div>
191 </div>
192 </header>
193 );
194}