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