| 1 | import { |
| 2 | Activity, |
| 3 | BarChart3, |
| 4 | BellOff, |
| 5 | BellRing, |
| 6 | BookOpen, |
| 7 | Building2, |
| 8 | Check, |
| 9 | ChevronRight, |
| 10 | CircleUserRound, |
| 11 | Code2, |
| 12 | House, |
| 13 | Inbox, |
| 14 | LifeBuoy, |
| 15 | LogOut, |
| 16 | MessagesSquare, |
| 17 | Moon, |
| 18 | Plug, |
| 19 | Plus, |
| 20 | Settings, |
| 21 | Smile, |
| 22 | Sparkles, |
| 23 | Sun, |
| 24 | Users, |
| 25 | UsersRound, |
| 26 | } from "lucide-react"; |
| 27 | import { Dialog as Primitive } from "radix-ui"; |
| 28 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 29 | import { Link, useLocation, useNavigate, useSubmit } from "react-router"; |
| 30 | |
| 31 | import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; |
| 32 | |
| 33 | import { Avatar } from "./ui"; |
| 34 | import { TabStrip } from "./ui/tab-strip"; |
| 35 | import { StatusDialog } from "./presence"; |
| 36 | import { setPresence, useOwnPresence } from "../lib/notify-client"; |
| 37 | import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence"; |
| 38 | import { STATUS_URL } from "../lib/status"; |
| 39 | import { modeOf } from "../lib/workspace-nav"; |
| 40 | import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome"; |
| 41 | import { ROADMAP, type RoadmapItem } from "../lib/roadmap"; |
| 42 | |
| 43 | /** |
| 44 | * The phone's layout (below 768px): a tab bar along the bottom, the |
| 45 | * workspace's avatar opening a sheet for everything else, and each tab a |
| 46 | * list that pushes its detail full screen. Desktop keeps the rail. |
| 47 | */ |
| 48 | |
| 49 | /** Whether a path is one conversation, which takes the whole screen on a phone. */ |
| 50 | export function isConversation(pathname: string): boolean { |
| 51 | return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Follows the visual viewport, which shrinks when the keyboard opens: its |
| 56 | * height and top go on the page as `--vv-height` and `--vv-top`, what |
| 57 | * the keyboard covers as `--keyboard-inset`, and |
| 58 | * `data-keyboard="open"` on <html> while the keyboard is up, so a |
| 59 | * composer can sit just above it and the tab bar can step aside. |
| 60 | */ |
| 61 | export function useVisualViewport() { |
| 62 | useEffect(() => { |
| 63 | const viewport = window.visualViewport; |
| 64 | if (!viewport) return; |
| 65 | const root = document.documentElement; |
| 66 | const update = () => { |
| 67 | root.style.setProperty("--vv-height", `${viewport.height}px`); |
| 68 | root.style.setProperty("--vv-top", `${viewport.offsetTop}px`); |
| 69 | // How much of the bottom the keyboard covers, for a sheet to sit on it. |
| 70 | root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`); |
| 71 | const keyboard = window.innerHeight - viewport.height > 140; |
| 72 | if (keyboard) root.dataset.keyboard = "open"; |
| 73 | else delete root.dataset.keyboard; |
| 74 | }; |
| 75 | update(); |
| 76 | viewport.addEventListener("resize", update); |
| 77 | viewport.addEventListener("scroll", update); |
| 78 | return () => { |
| 79 | viewport.removeEventListener("resize", update); |
| 80 | viewport.removeEventListener("scroll", update); |
| 81 | }; |
| 82 | }, []); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Swipe from the left edge to go back, as on a phone's own apps: a touch |
| 87 | * that starts within 28px of the edge and travels 80px right, more across |
| 88 | * than down. Going back is the browser's own back, so history stays true. |
| 89 | */ |
| 90 | export function useSwipeBack(onBack: () => void) { |
| 91 | const start = useRef<{ x: number; y: number } | null>(null); |
| 92 | return { |
| 93 | onTouchStart: (event: React.TouchEvent) => { |
| 94 | const touch = event.touches[0]; |
| 95 | start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null; |
| 96 | }, |
| 97 | onTouchMove: (event: React.TouchEvent) => { |
| 98 | const touch = event.touches[0]; |
| 99 | if (!start.current || !touch) return; |
| 100 | const dx = touch.clientX - start.current.x; |
| 101 | const dy = Math.abs(touch.clientY - start.current.y); |
| 102 | if (dx > 80 && dx > dy * 1.5) { |
| 103 | start.current = null; |
| 104 | onBack(); |
| 105 | } |
| 106 | }, |
| 107 | onTouchEnd: () => { |
| 108 | start.current = null; |
| 109 | }, |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | /** Back, as the browser would; to `fallback` when this page was the first one opened. */ |
| 114 | export function useBack(fallback: string) { |
| 115 | const navigate = useNavigate(); |
| 116 | return () => { |
| 117 | const index = (window.history.state as { idx?: number } | null)?.idx ?? 0; |
| 118 | if (index > 0) navigate(-1); |
| 119 | else navigate(fallback, { replace: true }); |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | type Tab = { key: string; label: string; to: string; icon: ReactNode; badge?: { count: number; loud: boolean } }; |
| 124 | |
| 125 | /** |
| 126 | * The tab bar along the bottom of a phone: Home, Code (Docs, for a member |
| 127 | * without Code), Chat, Agents and the Inbox, each with what is unread. It |
| 128 | * steps aside while the keyboard is up and inside a conversation. The tab |
| 129 | * you are in, tapped again, opens its mode's menu. |
| 130 | */ |
| 131 | export function MobileTabBar({ |
| 132 | workspace, |
| 133 | unread, |
| 134 | onReselect, |
| 135 | }: { |
| 136 | workspace: Membership; |
| 137 | unread: { inbox: number; chat: number; mentions: number }; |
| 138 | /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */ |
| 139 | onReselect?: () => void; |
| 140 | }) { |
| 141 | const { pathname } = useLocation(); |
| 142 | const slug = workspace.slug; |
| 143 | const code = hasCodeAccess(workspace); |
| 144 | const mode = modeOf(pathname, slug); |
| 145 | const tabs: Tab[] = [ |
| 146 | { key: "home", label: "Home", to: `/${slug}/-/home`, icon: <House size={21} /> }, |
| 147 | code |
| 148 | ? { key: "code", label: "Code", to: `/${slug}/-/projects`, icon: <Code2 size={21} /> } |
| 149 | : { key: "docs", label: "Docs", to: `/${slug}/-/docs`, icon: <BookOpen size={21} /> }, |
| 150 | { |
| 151 | key: "chat", |
| 152 | label: "Chat", |
| 153 | to: `/${slug}/-/chat`, |
| 154 | icon: <MessagesSquare size={21} />, |
| 155 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, |
| 156 | }, |
| 157 | { key: "agents", label: "Agents", to: `/${slug}/-/agents`, icon: <Sparkles size={21} /> }, |
| 158 | { key: "inbox", label: "Inbox", to: "/inbox", icon: <Inbox size={21} />, badge: { count: unread.inbox, loud: true } }, |
| 159 | ]; |
| 160 | if (isConversation(pathname)) return null; |
| 161 | return ( |
| 162 | <nav |
| 163 | aria-label="Tabs" |
| 164 | className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-[#0b0b0d]/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden in-data-[keyboard=open]:hidden" |
| 165 | > |
| 166 | <ul className="grid h-14 grid-cols-5"> |
| 167 | {tabs.map((tab) => { |
| 168 | const current = mode === tab.key; |
| 169 | const count = tab.badge?.count ?? 0; |
| 170 | return ( |
| 171 | <li key={tab.key}> |
| 172 | <Link |
| 173 | to={tab.to} |
| 174 | prefetch="intent" |
| 175 | aria-current={current ? "page" : undefined} |
| 176 | aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label} |
| 177 | onClick={(event) => { |
| 178 | // The tab you are in, tapped again: its menu, as a phone's own apps do. |
| 179 | // Chat's first page on a phone is its sidebar already. |
| 180 | if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === tab.to)) return; |
| 181 | event.preventDefault(); |
| 182 | onReselect(); |
| 183 | }} |
| 184 | className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60" |
| 185 | > |
| 186 | <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}> |
| 187 | {tab.icon} |
| 188 | {count > 0 && ( |
| 189 | <span |
| 190 | className={`absolute -top-1 right-0.5 min-w-[1.125rem] rounded-full px-1 text-center text-[0.625rem] leading-[1.125rem] font-bold tabular-nums ring-2 ring-[#0b0b0d] ${ |
| 191 | tab.badge?.loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg" |
| 192 | }`} |
| 193 | > |
| 194 | {count > 99 ? "99+" : count} |
| 195 | </span> |
| 196 | )} |
| 197 | </span> |
| 198 | <span className={`text-[0.6875rem] font-medium ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span> |
| 199 | </Link> |
| 200 | </li> |
| 201 | ); |
| 202 | })} |
| 203 | </ul> |
| 204 | </nav> |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * A sheet that rises from the bottom of a phone, with a handle to drag it |
| 210 | * back down. Escape, a tap outside or a drag of more than 90px closes it. |
| 211 | */ |
| 212 | export function BottomSheet({ |
| 213 | open, |
| 214 | onOpenChange, |
| 215 | title, |
| 216 | children, |
| 217 | }: { |
| 218 | open: boolean; |
| 219 | onOpenChange: (open: boolean) => void; |
| 220 | title: string; |
| 221 | children: ReactNode; |
| 222 | }) { |
| 223 | const [drag, setDrag] = useState(0); |
| 224 | const from = useRef<number | null>(null); |
| 225 | useEffect(() => { |
| 226 | if (!open) setDrag(0); |
| 227 | }, [open]); |
| 228 | const handlers = { |
| 229 | onPointerDown: (event: React.PointerEvent) => { |
| 230 | from.current = event.clientY; |
| 231 | (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); |
| 232 | }, |
| 233 | onPointerMove: (event: React.PointerEvent) => { |
| 234 | if (from.current == null) return; |
| 235 | setDrag(Math.max(0, event.clientY - from.current)); |
| 236 | }, |
| 237 | onPointerUp: () => { |
| 238 | if (drag > 90) onOpenChange(false); |
| 239 | else setDrag(0); |
| 240 | from.current = null; |
| 241 | }, |
| 242 | }; |
| 243 | return ( |
| 244 | <Primitive.Root open={open} onOpenChange={onOpenChange}> |
| 245 | <Primitive.Portal> |
| 246 | <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" /> |
| 247 | <Primitive.Content |
| 248 | aria-describedby={undefined} |
| 249 | onOpenAutoFocus={(event) => event.preventDefault()} |
| 250 | style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }} |
| 251 | className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col rounded-t-2xl border-t border-line-strong bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]" |
| 252 | > |
| 253 | <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true"> |
| 254 | <span className="h-1.5 w-10 rounded-full bg-line-strong" /> |
| 255 | </div> |
| 256 | <Primitive.Title className="sr-only">{title}</Primitive.Title> |
| 257 | <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div> |
| 258 | </Primitive.Content> |
| 259 | </Primitive.Portal> |
| 260 | </Primitive.Root> |
| 261 | ); |
| 262 | } |
| 263 | |
| 264 | /** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */ |
| 265 | export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) { |
| 266 | const className = "flex h-12 w-full items-center gap-3 rounded-xl px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised"; |
| 267 | const inner = ( |
| 268 | <> |
| 269 | <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span> |
| 270 | <span className="min-w-0 grow truncate">{children}</span> |
| 271 | {end} |
| 272 | </> |
| 273 | ); |
| 274 | if (to) { |
| 275 | return ( |
| 276 | <Link to={to} className={className} onClick={onClick}> |
| 277 | {inner} |
| 278 | </Link> |
| 279 | ); |
| 280 | } |
| 281 | if (href) { |
| 282 | return ( |
| 283 | <a href={href} className={className}> |
| 284 | {inner} |
| 285 | </a> |
| 286 | ); |
| 287 | } |
| 288 | return ( |
| 289 | <button type="button" onClick={onClick} className={className}> |
| 290 | {inner} |
| 291 | </button> |
| 292 | ); |
| 293 | } |
| 294 | |
| 295 | function SheetGroup({ title, children }: { title: string; children: ReactNode }) { |
| 296 | return ( |
| 297 | <section className="mt-2 border-t border-line pt-2"> |
| 298 | <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3> |
| 299 | {children} |
| 300 | </section> |
| 301 | ); |
| 302 | } |
| 303 | |
| 304 | function displayName(membership: Membership): string { |
| 305 | return membership.name?.trim() || membership.slug; |
| 306 | } |
| 307 | |
| 308 | /** Your status, away and pausing notifications, as the account menu has them on a computer. */ |
| 309 | function OwnPresenceRows({ onEdit }: { onEdit: () => void }) { |
| 310 | const me = useOwnPresence(); |
| 311 | const now = Date.now(); |
| 312 | const status = liveStatus(me?.status, now); |
| 313 | const paused = dndOn(me, now); |
| 314 | const away = me?.away_manual === true; |
| 315 | return ( |
| 316 | <> |
| 317 | <SheetRow icon={status?.emoji ? <span className="text-lg leading-none">{status.emoji}</span> : <Smile />} onClick={onEdit}> |
| 318 | {status ? status.text || "Status set" : "Set a status"} |
| 319 | </SheetRow> |
| 320 | <SheetRow icon={away ? <Sun /> : <Moon />} onClick={() => void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" })}> |
| 321 | {away ? "Set yourself active" : "Set yourself away"} |
| 322 | </SheetRow> |
| 323 | {paused ? ( |
| 324 | <SheetRow |
| 325 | icon={<BellRing />} |
| 326 | end={me?.dnd_until ? <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now))}</span> : null} |
| 327 | onClick={() => void setPresence({ dnd_until: null }, { dnd_until: null })} |
| 328 | > |
| 329 | Resume notifications |
| 330 | </SheetRow> |
| 331 | ) : ( |
| 332 | <SheetRow |
| 333 | icon={<BellOff />} |
| 334 | onClick={() => { |
| 335 | const until = pauseUntil("1h", new Date()); |
| 336 | void setPresence({ dnd_until: until }, { dnd_until: until }); |
| 337 | }} |
| 338 | > |
| 339 | Pause notifications for 1 hour |
| 340 | </SheetRow> |
| 341 | )} |
| 342 | </> |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * The phone's everything-else: the workspace avatar at the top left opens |
| 348 | * it. Docs and the workspace's own pages first, then your workspaces, |
| 349 | * help, and your account. The mode you are in has its own menu (its |
| 350 | * sidebar), behind the button beside the avatar. |
| 351 | */ |
| 352 | export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) { |
| 353 | const [open, setOpen] = useState(false); |
| 354 | const { pathname } = useLocation(); |
| 355 | const submit = useSubmit(); |
| 356 | useEffect(() => setOpen(false), [pathname]); |
| 357 | const slug = workspace.slug; |
| 358 | const owner = workspace.role === "owner"; |
| 359 | const [editing, setEditing] = useState(false); |
| 360 | return ( |
| 361 | <> |
| 362 | <button |
| 363 | type="button" |
| 364 | aria-label={`${displayName(workspace)}: workspaces, settings and account`} |
| 365 | onClick={() => setOpen(true)} |
| 366 | className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform active:scale-95 md:hidden" |
| 367 | > |
| 368 | <Avatar name={slug} image={workspace.avatar} size={32} square /> |
| 369 | </button> |
| 370 | <BottomSheet open={open} onOpenChange={setOpen} title="Workspaces, settings and account"> |
| 371 | <div className="flex items-center gap-3 px-3 pt-1 pb-3"> |
| 372 | <Avatar name={slug} image={workspace.avatar} size={44} square /> |
| 373 | <div className="min-w-0"> |
| 374 | <p className="truncate text-base font-semibold">{displayName(workspace)}</p> |
| 375 | <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p> |
| 376 | </div> |
| 377 | </div> |
| 378 | <SheetGroup title="Workspace"> |
| 379 | {/* Docs has no tab of its own for someone with Code: it leads here. */} |
| 380 | {hasCodeAccess(workspace) && ( |
| 381 | <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />} end={<ChevronRight size={16} className="text-faint" />}> |
| 382 | Docs |
| 383 | </SheetRow> |
| 384 | )} |
| 385 | <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}> |
| 386 | Overview |
| 387 | </SheetRow> |
| 388 | <SheetRow to={`/${slug}/-/people`} icon={<Users />}> |
| 389 | People |
| 390 | </SheetRow> |
| 391 | <SheetRow to={`/${slug}/-/teams`} icon={<UsersRound />}> |
| 392 | Teams |
| 393 | </SheetRow> |
| 394 | <SheetRow to={`/${slug}/-/usage`} icon={<BarChart3 />}> |
| 395 | Usage and billing |
| 396 | </SheetRow> |
| 397 | <SheetRow to={`/${slug}/-/integrations`} icon={<Plug />}> |
| 398 | Integrations |
| 399 | </SheetRow> |
| 400 | <SheetRow to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings />}> |
| 401 | Settings |
| 402 | </SheetRow> |
| 403 | </SheetGroup> |
| 404 | <SheetGroup title="Switch workspace"> |
| 405 | {(user.workspaces ?? []).map((membership) => ( |
| 406 | <SheetRow |
| 407 | key={membership.slug} |
| 408 | to={`/${membership.slug}/-/home`} |
| 409 | icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />} |
| 410 | end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null} |
| 411 | > |
| 412 | {displayName(membership)} |
| 413 | </SheetRow> |
| 414 | ))} |
| 415 | <SheetRow to="/workspaces/new" icon={<Plus />}> |
| 416 | New workspace |
| 417 | </SheetRow> |
| 418 | </SheetGroup> |
| 419 | <SheetGroup title="Help"> |
| 420 | <SheetRow to="/support" icon={<LifeBuoy />}> |
| 421 | Support |
| 422 | </SheetRow> |
| 423 | <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}> |
| 424 | Documentation |
| 425 | </SheetRow> |
| 426 | <SheetRow href={STATUS_URL} icon={<Activity />}> |
| 427 | Status |
| 428 | </SheetRow> |
| 429 | </SheetGroup> |
| 430 | <SheetGroup title={`@${shownUsername(user)}`}> |
| 431 | <OwnPresenceRows |
| 432 | onEdit={() => { |
| 433 | setOpen(false); |
| 434 | setEditing(true); |
| 435 | }} |
| 436 | /> |
| 437 | <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}> |
| 438 | Your profile |
| 439 | </SheetRow> |
| 440 | <SheetRow to="/settings" icon={<Settings />}> |
| 441 | Your settings |
| 442 | </SheetRow> |
| 443 | <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}> |
| 444 | Sign out |
| 445 | </SheetRow> |
| 446 | </SheetGroup> |
| 447 | </BottomSheet> |
| 448 | <StatusDialog open={editing} onOpenChange={setEditing} /> |
| 449 | </> |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | /** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */ |
| 454 | const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = { |
| 455 | Code: "code", |
| 456 | Issues: "issues", |
| 457 | Agents: "agents", |
| 458 | Deployments: "deployments", |
| 459 | Observability: "observability", |
| 460 | Security: "security", |
| 461 | Insights: "insights", |
| 462 | }; |
| 463 | const SOON_PAGES: Record<string, ProjectPage> = Object.fromEntries( |
| 464 | ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])), |
| 465 | ); |
| 466 | |
| 467 | /** |
| 468 | * A project's pages on a phone, under its name: the sidebar's own list |
| 469 | * (lib/chrome.ts) as a row of tabs that scrolls sideways, the current one |
| 470 | * kept in view, so Issues and Pull requests are one tap away. From 768px |
| 471 | * the sidebar has them. |
| 472 | */ |
| 473 | export function ProjectStrip({ |
| 474 | base, |
| 475 | member, |
| 476 | can, |
| 477 | settings, |
| 478 | counts, |
| 479 | }: { |
| 480 | /** `/<namespace>/<name>`. */ |
| 481 | base: string; |
| 482 | member: boolean; |
| 483 | can?: Partial<Abilities>; |
| 484 | /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */ |
| 485 | settings?: boolean; |
| 486 | counts?: { issues: number; pulls: number }; |
| 487 | }) { |
| 488 | const { pathname } = useLocation(); |
| 489 | const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : ""; |
| 490 | const at = projectPageAt(rest, SOON_PAGES); |
| 491 | const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"), |
| 492 | ...(settings ?? member ? (["settings"] as const) : []), |
| 493 | ]; |
| 494 | const pill = (current: boolean) => |
| 495 | `flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm transition-colors ${ |
| 496 | current ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted active:bg-raised/60" |
| 497 | }`; |
| 498 | return ( |
| 499 | <TabStrip label="Project" className="-mx-4 gap-1 px-4 md:hidden"> |
| 500 | {pages.map((page) => { |
| 501 | const current = at === page; |
| 502 | const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page]; |
| 503 | const count = page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined; |
| 504 | return ( |
| 505 | <Link |
| 506 | key={page} |
| 507 | to={link.path ? `${base}/${link.path}` : base} |
| 508 | prefetch="intent" |
| 509 | aria-current={current ? "page" : undefined} |
| 510 | data-active={current || undefined} |
| 511 | className={pill(current)} |
| 512 | > |
| 513 | {link.label} |
| 514 | {count != null && count > 0 && <span className="rounded-full bg-line px-1.5 text-xs tabular-nums text-muted">{count}</span>} |
| 515 | </Link> |
| 516 | ); |
| 517 | })} |
| 518 | </TabStrip> |
| 519 | ); |
| 520 | } |