Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Integrations are a directory of connectors, for a workspace and for each person | 1 | import { |
| 2 | AlertTriangle, | |
| 3 | BarChart3, | |
| 4 | BookOpen, | |
| 5 | Bot, | |
| 6 | CalendarDays, | |
| 7 | ChevronRight, | |
| 8 | Cloud, | |
| 9 | Code2, | |
| 10 | FolderOpen, | |
| 11 | Headset, | |
| 12 | LayoutGrid, | |
| 13 | ListChecks, | |
| 14 | MessagesSquare, | |
| 15 | Palette, | |
| 16 | Search, | |
| 17 | ShieldCheck, | |
| 18 | Siren, | |
| 19 | } from "lucide-react"; | |
| 20 | import { type ReactNode, useState } from "react"; | |
| 21 | import { Link, useSearchParams } from "react-router"; | |
| 22 | ||
| 23 | import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; | |
| 24 | ||
| 25 | import { CONTACT } from "../lib/legal"; | |
| 26 | import { | |
| 27 | type CategoryFilter, | |
| 28 | type ConnectedState, | |
| 29 | arrange, | |
| 30 | askHref, | |
| 31 | categoriesWith, | |
| 32 | categoryCounts, | |
| 33 | categoryFilter, | |
| 34 | categoryTitle, | |
| 35 | groupByCategory, | |
| 36 | monogram, | |
| 37 | tileColor, | |
| 38 | } from "../lib/connectors"; | |
| 39 | import { cn } from "../lib/cn"; | |
| 40 | import { ProviderMark } from "./model-providers"; | |
| 41 | import { Badge } from "./ui/badge"; | |
| 42 | import { Hint } from "./ui/hint"; | |
| 43 | import { Input } from "./ui"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 44 | import { Card } from "./ui/card"; |
| Integrations are a directory of connectors, for a workspace and for each person | 45 | |
| 46 | const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = { | |
| 47 | all: <LayoutGrid size={15} />, | |
| 48 | code: <Code2 size={15} />, | |
| 49 | issues: <ListChecks size={15} />, | |
| 50 | chat: <MessagesSquare size={15} />, | |
| 51 | calendar: <CalendarDays size={15} />, | |
| 52 | docs: <BookOpen size={15} />, | |
| 53 | monitoring: <Siren size={15} />, | |
| 54 | cloud: <Cloud size={15} />, | |
| 55 | ai: <Bot size={15} />, | |
| 56 | data: <BarChart3 size={15} />, | |
| 57 | crm: <Headset size={15} />, | |
| 58 | design: <Palette size={15} />, | |
| 59 | security: <ShieldCheck size={15} />, | |
| 60 | files: <FolderOpen size={15} />, | |
| 61 | }; | |
| 62 | ||
| 63 | /** A connector's tile: its provider's mark, or its letters on a steady colour. No logos. */ | |
| 64 | export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) { | |
| 65 | if (view.provider) return <ProviderMark provider={view.provider} size={size} />; | |
| 66 | const color = tileColor(view.id); | |
| 67 | const letters = monogram(view.name); | |
| 68 | return ( | |
| 69 | <span | |
| 70 | aria-hidden="true" | |
| 71 | className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight" | |
| 72 | style={{ | |
| 73 | width: size, | |
| 74 | height: size, | |
| 75 | borderRadius: size * 0.28, | |
| 76 | fontSize: size * (letters.length > 1 ? 0.34 : 0.42), | |
| 77 | background: `color-mix(in srgb, ${color} 16%, transparent)`, | |
| The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how. | 78 | // Toward the text colour, so the letters read in both themes. |
| 79 | color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`, | |
| Integrations are a directory of connectors, for a workspace and for each person | 80 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, |
| 81 | }} | |
| 82 | > | |
| 83 | {letters} | |
| 84 | </span> | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | export type ConnectorDirectoryProps = { | |
| 89 | scope: ConnectorScope; | |
| 90 | views: ConnectorView[]; | |
| 91 | /** What is connected here, by connector id. */ | |
| 92 | connected: Record<string, ConnectedState>; | |
| 93 | /** The workspace's slug on its page; null on your own. */ | |
| 94 | workspace: string | null; | |
| 95 | /** Whether the viewer can connect things here (an owner, or anyone for themselves). */ | |
| 96 | canManage: boolean; | |
| 97 | /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */ | |
| 98 | nav?: "sidebar" | "chips"; | |
| 99 | }; | |
| 100 | ||
| 101 | /** | |
| 102 | * The integrations directory: categories to browse, a search, and every | |
| 103 | * connector as a card, sorted into Connected, Available and Soon. | |
| 104 | */ | |
| 105 | export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) { | |
| 106 | const [params, setParams] = useSearchParams(); | |
| 107 | const category = categoryFilter(params.get("category")); | |
| 108 | const [query, setQuery] = useState(""); | |
| 109 | const counts = categoryCounts(views); | |
| 110 | const categories = categoriesWith(views); | |
| 111 | const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) }); | |
| 112 | const searching = query.trim().length > 0; | |
| 113 | const grouped = category === "all" && !searching; | |
| 114 | const empty = found.connected.length + found.available.length + found.soon.length === 0; | |
| 115 | ||
| 116 | const choose = (next: CategoryFilter) => { | |
| 117 | setParams( | |
| 118 | (current) => { | |
| 119 | const updated = new URLSearchParams(current); | |
| 120 | if (next === "all") updated.delete("category"); | |
| 121 | else updated.set("category", next); | |
| 122 | return updated; | |
| 123 | }, | |
| 124 | { replace: true, preventScrollReset: true }, | |
| 125 | ); | |
| 126 | setQuery(""); | |
| 127 | }; | |
| 128 | ||
| 129 | const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories]; | |
| 130 | const card = (view: ConnectorView) => ( | |
| 131 | <ConnectorCard | |
| 132 | key={view.id} | |
| 133 | view={view} | |
| 134 | scope={scope} | |
| 135 | state={connected[view.id] ?? null} | |
| 136 | workspace={workspace} | |
| 137 | canManage={canManage} | |
| 138 | showCategory={category === "all" || searching} | |
| 139 | /> | |
| 140 | ); | |
| 141 | ||
| 142 | return ( | |
| 143 | <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}> | |
| 144 | {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */} | |
| 145 | <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}> | |
| 146 | <ul | |
| 147 | className={cn( | |
| 148 | "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0", | |
| 149 | nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0", | |
| 150 | )} | |
| 151 | > | |
| 152 | {options.map((option) => { | |
| 153 | const current = option.id === category; | |
| 154 | return ( | |
| 155 | <li key={option.id} className="shrink-0"> | |
| 156 | <button | |
| 157 | type="button" | |
| 158 | onClick={() => choose(option.id)} | |
| 159 | aria-current={current ? "page" : undefined} | |
| 160 | className={cn( | |
| 161 | "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors", | |
| 162 | nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5", | |
| 163 | current | |
| 164 | ? "border-accent/40 bg-accent/10 text-fg md:bg-raised" | |
| 165 | : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface", | |
| 166 | )} | |
| 167 | > | |
| 168 | <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}> | |
| 169 | {CATEGORY_ICONS[option.id]} | |
| 170 | </span> | |
| 171 | <span className="grow text-left">{option.title}</span> | |
| 172 | <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span> | |
| 173 | </button> | |
| 174 | </li> | |
| 175 | ); | |
| 176 | })} | |
| 177 | </ul> | |
| 178 | </nav> | |
| 179 | ||
| 180 | <div className="mt-5 min-w-0 md:mt-0"> | |
| 181 | <div className="relative"> | |
| 182 | <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 183 | <Input | |
| 184 | type="search" | |
| 185 | value={query} | |
| 186 | onChange={(event) => setQuery(event.target.value)} | |
| 187 | placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`} | |
| 188 | aria-label="Search integrations" | |
| 189 | className="pl-9" | |
| 190 | /> | |
| 191 | </div> | |
| 192 | ||
| 193 | {empty && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 194 | <Card asChild tone="plain" className="mt-8 border-dashed px-4 py-8 text-center text-sm text-muted"> |
| 195 | <p> | |
| 196 | Nothing matches “{query.trim()}”.{" "} | |
| 197 | <a | |
| 198 | href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })} | |
| 199 | className="text-accent hover:underline" | |
| 200 | > | |
| 201 | Ask for it | |
| 202 | </a> | |
| 203 | , and say what you would use it for. | |
| 204 | </p> | |
| 205 | </Card> | |
| Integrations are a directory of connectors, for a workspace and for each person | 206 | )} |
| 207 | ||
| 208 | {found.connected.length > 0 && ( | |
| 209 | <Group title="Connected" count={found.connected.length}> | |
| 210 | <Cards>{found.connected.map(card)}</Cards> | |
| 211 | </Group> | |
| 212 | )} | |
| 213 | {found.available.length > 0 && ( | |
| 214 | <Group | |
| 215 | title="Available" | |
| 216 | count={found.available.length} | |
| 217 | about={ | |
| 218 | scope === "workspace" | |
| 219 | ? canManage | |
| 220 | ? "Ready to connect now." | |
| 221 | : `Ready now. An owner of ${workspace} connects them.` | |
| 222 | : "Ready to connect now." | |
| 223 | } | |
| 224 | > | |
| 225 | <Cards>{found.available.map(card)}</Cards> | |
| 226 | </Group> | |
| 227 | )} | |
| 228 | {found.soon.length > 0 && ( | |
| 229 | <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner."> | |
| 230 | {grouped ? ( | |
| 231 | <div className="space-y-6"> | |
| 232 | {groupByCategory(found.soon).map((group) => ( | |
| 233 | <div key={group.id}> | |
| 234 | <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase"> | |
| 235 | {CATEGORY_ICONS[group.id]} | |
| 236 | {group.title} | |
| 237 | </p> | |
| 238 | <Cards>{group.views.map((view) => <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />)}</Cards> | |
| 239 | </div> | |
| 240 | ))} | |
| 241 | </div> | |
| 242 | ) : ( | |
| 243 | <Cards>{found.soon.map(card)}</Cards> | |
| 244 | )} | |
| 245 | </Group> | |
| 246 | )} | |
| 247 | </div> | |
| 248 | </div> | |
| 249 | ); | |
| 250 | } | |
| 251 | ||
| 252 | function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) { | |
| 253 | return ( | |
| 254 | <section className="mt-8"> | |
| 255 | <h2 className="flex items-baseline gap-2 text-sm font-medium"> | |
| 256 | {title} | |
| 257 | <span className="text-xs font-normal text-faint tabular-nums">{count}</span> | |
| 258 | </h2> | |
| 259 | {about && <p className="mt-0.5 text-xs text-muted">{about}</p>} | |
| 260 | <div className="mt-3">{children}</div> | |
| 261 | </section> | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 265 | function Cards({ children }: { children: ReactNode }) { | |
| 266 | return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>; | |
| 267 | } | |
| 268 | ||
| 269 | function ConnectorCard({ | |
| 270 | view, | |
| 271 | scope, | |
| 272 | state, | |
| 273 | workspace, | |
| 274 | canManage, | |
| 275 | showCategory, | |
| 276 | }: { | |
| 277 | view: ConnectorView; | |
| 278 | scope: ConnectorScope; | |
| 279 | state: ConnectedState | null; | |
| 280 | workspace: string | null; | |
| 281 | canManage: boolean; | |
| 282 | showCategory: boolean; | |
| 283 | }) { | |
| 284 | const soon = view.status === "soon"; | |
| 285 | const setup = view.href ? connectorPath(view.href, workspace ?? "") : null; | |
| 286 | const to = state?.manage ?? setup; | |
| 287 | const also = | |
| 288 | view.alsoIn === "personal" | |
| 289 | ? "Each person can also connect their own, under their settings." | |
| 290 | : view.alsoIn === "workspace" | |
| 291 | ? "A workspace owner can also connect it for everyone." | |
| 292 | : null; | |
| 293 | return ( | |
| 294 | <li | |
| 295 | className={cn( | |
| 296 | "relative flex flex-col rounded-xl border p-4 transition-colors", | |
| 297 | soon ? "border-line/70 bg-surface/40" : "border-line bg-surface", | |
| 298 | !soon && to && "hover:border-line-strong", | |
| 299 | )} | |
| 300 | > | |
| 301 | <div className="flex items-start gap-3"> | |
| 302 | <span className={cn(soon && "opacity-70 grayscale-[35%]")}> | |
| 303 | <ConnectorMark view={view} /> | |
| 304 | </span> | |
| 305 | <div className="min-w-0 grow"> | |
| 306 | <div className="flex items-center gap-2"> | |
| 307 | {!soon && to ? ( | |
| 308 | // The whole card opens it: the link stretches over the card. | |
| 309 | <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none"> | |
| 310 | {view.name} | |
| 311 | </Link> | |
| 312 | ) : ( | |
| 313 | <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p> | |
| 314 | )} | |
| 315 | {state ? ( | |
| 316 | state.problem ? ( | |
| 317 | <Hint label={state.problem}> | |
| 318 | <Badge tone="warn" className="relative z-10" tabIndex={0}> | |
| 319 | <AlertTriangle size={11} /> Needs attention | |
| 320 | </Badge> | |
| 321 | </Hint> | |
| 322 | ) : ( | |
| 323 | <Badge tone="success">Connected</Badge> | |
| 324 | ) | |
| 325 | ) : soon ? ( | |
| 326 | <Badge>Soon</Badge> | |
| 327 | ) : null} | |
| 328 | </div> | |
| 329 | {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>} | |
| 330 | </div> | |
| 331 | </div> | |
| 332 | <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p> | |
| 333 | {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>} | |
| 334 | {view.capabilities.length > 0 && ( | |
| 335 | <ul className="mt-3 flex flex-wrap gap-1.5"> | |
| 336 | {view.capabilities.map((capability) => ( | |
| 337 | <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted"> | |
| 338 | {capability} | |
| 339 | </li> | |
| 340 | ))} | |
| 341 | </ul> | |
| 342 | )} | |
| 343 | <div className="mt-auto flex items-end justify-between gap-3 pt-3"> | |
| 344 | <p className="text-[0.6875rem] text-faint">{also}</p> | |
| 345 | {soon ? ( | |
| 346 | <a | |
| 347 | href={askHref({ connector: view, scope, workspace, address: CONTACT.support })} | |
| 348 | className="relative z-10 shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg" | |
| 349 | > | |
| 350 | Ask for this | |
| 351 | </a> | |
| 352 | ) : to ? ( | |
| 353 | <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent"> | |
| 354 | {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"} | |
| 355 | <ChevronRight size={13} /> | |
| 356 | </span> | |
| 357 | ) : null} | |
| 358 | </div> | |
| 359 | </li> | |
| 360 | ); | |
| 361 | } | |
| 362 | ||
| 363 | /** A short line saying who a page's connections are for, and where the other kind lives. */ | |
| 364 | export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) { | |
| 365 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 366 | <Card asChild tone="plain" className="mb-6 bg-surface/60 px-4 py-3 text-sm text-muted"> |
| 367 | <p> | |
| 368 | {scope === "workspace" ? ( | |
| 369 | <> | |
| 370 | Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>: its people and its agents. | |
| 371 | Your own accounts, such as your calendar, are under{" "} | |
| 372 | <Link to="/settings/integrations" className="text-accent hover:underline"> | |
| 373 | your integrations | |
| 374 | </Link> | |
| 375 | . | |
| 376 | </> | |
| 377 | ) : ( | |
| 378 | <> | |
| 379 | Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools | |
| 380 | the whole team shares are connected by a workspace owner, under the workspace's Integrations. | |
| 381 | </> | |
| 382 | )} | |
| 383 | </p> | |
| 384 | </Card> | |
| Integrations are a directory of connectors, for a workspace and for each person | 385 | ); |
| 386 | } |