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