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.

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