| 1 | import { |
| 2 | Activity, |
| 3 | BarChart3, |
| 4 | BookOpen, |
| 5 | Building2, |
| 6 | Check, |
| 7 | CircleUserRound, |
| 8 | Code2, |
| 9 | House, |
| 10 | Inbox, |
| 11 | LifeBuoy, |
| 12 | LogOut, |
| 13 | MessagesSquare, |
| 14 | Plug, |
| 15 | Plus, |
| 16 | Settings, |
| 17 | Sparkles, |
| 18 | Users, |
| 19 | UsersRound, |
| 20 | } from "lucide-react"; |
| 21 | import { Dialog as Primitive } from "radix-ui"; |
| 22 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 23 | import { Link, useLocation, useNavigate, useSubmit } from "react-router"; |
| 24 | |
| 25 | import { type Membership, type User, hasCodeAccess } from "@g1t/contracts"; |
| 26 | |
| 27 | import { Avatar } from "./ui"; |
| 28 | import { STATUS_URL } from "../lib/status"; |
| 29 | import { modeOf } from "../lib/workspace-nav"; |
| 30 | |
| 31 | /** |
| 32 | * The phone's layout (below 768px): a tab bar along the bottom, the |
| 33 | * workspace's avatar opening a sheet for everything else, and each tab a |
| 34 | * list that pushes its detail full screen. Desktop keeps the rail. |
| 35 | */ |
| 36 | |
| 37 | /** Whether a path is one conversation, which takes the whole screen on a phone. */ |
| 38 | export function isConversation(pathname: string): boolean { |
| 39 | return /^\/[^/]+\/-\/chat\/(?!browse\/?$)[^/]+/.test(pathname); |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Follows the visual viewport, which shrinks when the keyboard opens: its |
| 44 | * height and top go on the page as `--vv-height` and `--vv-top`, and |
| 45 | * `data-keyboard="open"` on <html> while the keyboard is up, so a |
| 46 | * composer can sit just above it and the tab bar can step aside. |
| 47 | */ |
| 48 | export function useVisualViewport() { |
| 49 | useEffect(() => { |
| 50 | const viewport = window.visualViewport; |
| 51 | if (!viewport) return; |
| 52 | const root = document.documentElement; |
| 53 | const update = () => { |
| 54 | root.style.setProperty("--vv-height", `${viewport.height}px`); |
| 55 | root.style.setProperty("--vv-top", `${viewport.offsetTop}px`); |
| 56 | const keyboard = window.innerHeight - viewport.height > 140; |
| 57 | if (keyboard) root.dataset.keyboard = "open"; |
| 58 | else delete root.dataset.keyboard; |
| 59 | }; |
| 60 | update(); |
| 61 | viewport.addEventListener("resize", update); |
| 62 | viewport.addEventListener("scroll", update); |
| 63 | return () => { |
| 64 | viewport.removeEventListener("resize", update); |
| 65 | viewport.removeEventListener("scroll", update); |
| 66 | }; |
| 67 | }, []); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Swipe from the left edge to go back, as on a phone's own apps: a touch |
| 72 | * that starts within 28px of the edge and travels 80px right, more across |
| 73 | * than down. Going back is the browser's own back, so history stays true. |
| 74 | */ |
| 75 | export function useSwipeBack(onBack: () => void) { |
| 76 | const start = useRef<{ x: number; y: number } | null>(null); |
| 77 | return { |
| 78 | onTouchStart: (event: React.TouchEvent) => { |
| 79 | const touch = event.touches[0]; |
| 80 | start.current = touch && touch.clientX < 28 ? { x: touch.clientX, y: touch.clientY } : null; |
| 81 | }, |
| 82 | onTouchMove: (event: React.TouchEvent) => { |
| 83 | const touch = event.touches[0]; |
| 84 | if (!start.current || !touch) return; |
| 85 | const dx = touch.clientX - start.current.x; |
| 86 | const dy = Math.abs(touch.clientY - start.current.y); |
| 87 | if (dx > 80 && dx > dy * 1.5) { |
| 88 | start.current = null; |
| 89 | onBack(); |
| 90 | } |
| 91 | }, |
| 92 | onTouchEnd: () => { |
| 93 | start.current = null; |
| 94 | }, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | /** Back, as the browser would; to `fallback` when this page was the first one opened. */ |
| 99 | export function useBack(fallback: string) { |
| 100 | const navigate = useNavigate(); |
| 101 | return () => { |
| 102 | const index = (window.history.state as { idx?: number } | null)?.idx ?? 0; |
| 103 | if (index > 0) navigate(-1); |
| 104 | else navigate(fallback, { replace: true }); |
| 105 | }; |
| 106 | } |
| 107 | |
| 108 | type Tab = { key: string; label: string; to: string; icon: ReactNode; badge?: { count: number; loud: boolean } }; |
| 109 | |
| 110 | /** |
| 111 | * The tab bar along the bottom of a phone: Home, Chat, Agents, Code (Docs, |
| 112 | * for a member without Code) and the Inbox, each with what is unread. It |
| 113 | * steps aside while the keyboard is up and inside a conversation. |
| 114 | */ |
| 115 | export function MobileTabBar({ workspace, unread }: { workspace: Membership; unread: { inbox: number; chat: number; mentions: number } }) { |
| 116 | const { pathname } = useLocation(); |
| 117 | const slug = workspace.slug; |
| 118 | const code = hasCodeAccess(workspace); |
| 119 | const mode = modeOf(pathname, slug); |
| 120 | const tabs: Tab[] = [ |
| 121 | { key: "home", label: "Home", to: `/${slug}/-/home`, icon: <House size={21} /> }, |
| 122 | { |
| 123 | key: "chat", |
| 124 | label: "Chat", |
| 125 | to: `/${slug}/-/chat`, |
| 126 | icon: <MessagesSquare size={21} />, |
| 127 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, |
| 128 | }, |
| 129 | { key: "agents", label: "Agents", to: `/${slug}/-/agents`, icon: <Sparkles size={21} /> }, |
| 130 | code |
| 131 | ? { key: "code", label: "Code", to: `/${slug}/-/projects`, icon: <Code2 size={21} /> } |
| 132 | : { key: "docs", label: "Docs", to: `/${slug}/-/docs`, icon: <BookOpen size={21} /> }, |
| 133 | { key: "inbox", label: "Inbox", to: "/inbox", icon: <Inbox size={21} />, badge: { count: unread.inbox, loud: true } }, |
| 134 | ]; |
| 135 | if (isConversation(pathname)) return null; |
| 136 | return ( |
| 137 | <nav |
| 138 | aria-label="Tabs" |
| 139 | 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" |
| 140 | > |
| 141 | <ul className="grid h-14 grid-cols-5"> |
| 142 | {tabs.map((tab) => { |
| 143 | const current = mode === tab.key; |
| 144 | const count = tab.badge?.count ?? 0; |
| 145 | return ( |
| 146 | <li key={tab.key}> |
| 147 | <Link |
| 148 | to={tab.to} |
| 149 | prefetch="intent" |
| 150 | aria-current={current ? "page" : undefined} |
| 151 | aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label} |
| 152 | className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60" |
| 153 | > |
| 154 | <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}> |
| 155 | {tab.icon} |
| 156 | {count > 0 && ( |
| 157 | <span |
| 158 | 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] ${ |
| 159 | tab.badge?.loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg" |
| 160 | }`} |
| 161 | > |
| 162 | {count > 99 ? "99+" : count} |
| 163 | </span> |
| 164 | )} |
| 165 | </span> |
| 166 | <span className={`text-[0.6875rem] font-medium ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span> |
| 167 | </Link> |
| 168 | </li> |
| 169 | ); |
| 170 | })} |
| 171 | </ul> |
| 172 | </nav> |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * A sheet that rises from the bottom of a phone, with a handle to drag it |
| 178 | * back down. Escape, a tap outside or a drag of more than 90px closes it. |
| 179 | */ |
| 180 | export function BottomSheet({ |
| 181 | open, |
| 182 | onOpenChange, |
| 183 | title, |
| 184 | children, |
| 185 | }: { |
| 186 | open: boolean; |
| 187 | onOpenChange: (open: boolean) => void; |
| 188 | title: string; |
| 189 | children: ReactNode; |
| 190 | }) { |
| 191 | const [drag, setDrag] = useState(0); |
| 192 | const from = useRef<number | null>(null); |
| 193 | useEffect(() => { |
| 194 | if (!open) setDrag(0); |
| 195 | }, [open]); |
| 196 | const handlers = { |
| 197 | onPointerDown: (event: React.PointerEvent) => { |
| 198 | from.current = event.clientY; |
| 199 | (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); |
| 200 | }, |
| 201 | onPointerMove: (event: React.PointerEvent) => { |
| 202 | if (from.current == null) return; |
| 203 | setDrag(Math.max(0, event.clientY - from.current)); |
| 204 | }, |
| 205 | onPointerUp: () => { |
| 206 | if (drag > 90) onOpenChange(false); |
| 207 | else setDrag(0); |
| 208 | from.current = null; |
| 209 | }, |
| 210 | }; |
| 211 | return ( |
| 212 | <Primitive.Root open={open} onOpenChange={onOpenChange}> |
| 213 | <Primitive.Portal> |
| 214 | <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" /> |
| 215 | <Primitive.Content |
| 216 | aria-describedby={undefined} |
| 217 | onOpenAutoFocus={(event) => event.preventDefault()} |
| 218 | style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }} |
| 219 | 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)]" |
| 220 | > |
| 221 | <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true"> |
| 222 | <span className="h-1.5 w-10 rounded-full bg-line-strong" /> |
| 223 | </div> |
| 224 | <Primitive.Title className="sr-only">{title}</Primitive.Title> |
| 225 | <div className="min-h-0 grow overflow-y-auto overscroll-contain px-3 pb-3">{children}</div> |
| 226 | </Primitive.Content> |
| 227 | </Primitive.Portal> |
| 228 | </Primitive.Root> |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | /** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */ |
| 233 | export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) { |
| 234 | 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"; |
| 235 | const inner = ( |
| 236 | <> |
| 237 | <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span> |
| 238 | <span className="min-w-0 grow truncate">{children}</span> |
| 239 | {end} |
| 240 | </> |
| 241 | ); |
| 242 | if (to) { |
| 243 | return ( |
| 244 | <Link to={to} className={className} onClick={onClick}> |
| 245 | {inner} |
| 246 | </Link> |
| 247 | ); |
| 248 | } |
| 249 | if (href) { |
| 250 | return ( |
| 251 | <a href={href} className={className}> |
| 252 | {inner} |
| 253 | </a> |
| 254 | ); |
| 255 | } |
| 256 | return ( |
| 257 | <button type="button" onClick={onClick} className={className}> |
| 258 | {inner} |
| 259 | </button> |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | function SheetGroup({ title, children }: { title: string; children: ReactNode }) { |
| 264 | return ( |
| 265 | <section className="mt-2 border-t border-line pt-2"> |
| 266 | <h3 className="px-3 pt-1 pb-1 text-xs font-medium text-faint">{title}</h3> |
| 267 | {children} |
| 268 | </section> |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | function displayName(membership: Membership): string { |
| 273 | return membership.name?.trim() || membership.slug; |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * The phone's everything-else: the workspace avatar at the top left opens |
| 278 | * it. Your workspaces, Docs, the workspace's own pages, help, and your |
| 279 | * account. It replaces the menu sheet below 768px. |
| 280 | */ |
| 281 | export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) { |
| 282 | const [open, setOpen] = useState(false); |
| 283 | const { pathname } = useLocation(); |
| 284 | const submit = useSubmit(); |
| 285 | useEffect(() => setOpen(false), [pathname]); |
| 286 | const slug = workspace.slug; |
| 287 | const owner = workspace.role === "owner"; |
| 288 | return ( |
| 289 | <> |
| 290 | <button |
| 291 | type="button" |
| 292 | aria-label={`${displayName(workspace)}: workspaces, settings and account`} |
| 293 | onClick={() => setOpen(true)} |
| 294 | className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform active:scale-95 md:hidden" |
| 295 | > |
| 296 | <Avatar name={slug} image={workspace.avatar} size={32} square /> |
| 297 | </button> |
| 298 | <BottomSheet open={open} onOpenChange={setOpen} title="Workspaces, settings and account"> |
| 299 | <div className="flex items-center gap-3 px-3 pt-1 pb-3"> |
| 300 | <Avatar name={slug} image={workspace.avatar} size={44} square /> |
| 301 | <div className="min-w-0"> |
| 302 | <p className="truncate text-base font-semibold">{displayName(workspace)}</p> |
| 303 | <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p> |
| 304 | </div> |
| 305 | </div> |
| 306 | <SheetGroup title="Switch workspace"> |
| 307 | {(user.workspaces ?? []).map((membership) => ( |
| 308 | <SheetRow |
| 309 | key={membership.slug} |
| 310 | to={`/${membership.slug}/-/home`} |
| 311 | icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />} |
| 312 | end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null} |
| 313 | > |
| 314 | {displayName(membership)} |
| 315 | </SheetRow> |
| 316 | ))} |
| 317 | <SheetRow to="/workspaces/new" icon={<Plus />}> |
| 318 | New workspace |
| 319 | </SheetRow> |
| 320 | </SheetGroup> |
| 321 | <SheetGroup title="Workspace"> |
| 322 | <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />}> |
| 323 | Docs |
| 324 | </SheetRow> |
| 325 | <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}> |
| 326 | Overview |
| 327 | </SheetRow> |
| 328 | <SheetRow to={`/${slug}/-/people`} icon={<Users />}> |
| 329 | People |
| 330 | </SheetRow> |
| 331 | <SheetRow to={`/${slug}/-/teams`} icon={<UsersRound />}> |
| 332 | Teams |
| 333 | </SheetRow> |
| 334 | <SheetRow to={`/${slug}/-/usage`} icon={<BarChart3 />}> |
| 335 | Usage and billing |
| 336 | </SheetRow> |
| 337 | <SheetRow to={`/${slug}/-/integrations`} icon={<Plug />}> |
| 338 | Integrations |
| 339 | </SheetRow> |
| 340 | <SheetRow to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings />}> |
| 341 | Settings |
| 342 | </SheetRow> |
| 343 | </SheetGroup> |
| 344 | <SheetGroup title="Help"> |
| 345 | <SheetRow to="/support" icon={<LifeBuoy />}> |
| 346 | Support |
| 347 | </SheetRow> |
| 348 | <SheetRow href="https://docs.g1t.sh/" icon={<BookOpen />}> |
| 349 | Documentation |
| 350 | </SheetRow> |
| 351 | <SheetRow href={STATUS_URL} icon={<Activity />}> |
| 352 | Status |
| 353 | </SheetRow> |
| 354 | </SheetGroup> |
| 355 | <SheetGroup title={`@${user.username}`}> |
| 356 | <SheetRow to={`/u/${user.username}`} icon={<CircleUserRound />}> |
| 357 | Your profile |
| 358 | </SheetRow> |
| 359 | <SheetRow to="/settings" icon={<Settings />}> |
| 360 | Your settings |
| 361 | </SheetRow> |
| 362 | <SheetRow icon={<LogOut />} onClick={() => submit(null, { method: "post", action: "/logout" })}> |
| 363 | Sign out |
| 364 | </SheetRow> |
| 365 | </SheetGroup> |
| 366 | </BottomSheet> |
| 367 | </> |
| 368 | ); |
| 369 | } |