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