Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

534 linesCodeBlameRaw
1import {
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 Webhook,
20} from "lucide-react";
21import { type ReactNode, useState } from "react";
22import { Link, useSearchParams } from "react-router";
23
24import { type AiProvider, AI_PROVIDERS, type Provider, PROVIDERS } from "@g1t/contracts";
25import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
26
27import { AI_PROVIDER_LOOKS, isEndpoint } from "../lib/ai-providers";
28import { type BrandIcon, brandIcon } from "../lib/brand-icons";
29import { CONTACT } from "../lib/legal";
30import {
31 type CategoryFilter,
32 type ConnectedState,
33 arrange,
34 askHref,
35 categoriesWith,
36 categoryCounts,
37 categoryFilter,
38 categoryTitle,
39 groupByCategory,
40 monogram,
41 tileColor,
42} from "../lib/connectors";
43import { cn } from "../lib/cn";
44import { providerPage } from "../lib/integration-sections";
45import { Badge } from "./ui/badge";
46import { Button } from "./ui/button";
47import { Hint } from "./ui/hint";
48import { Input, InputAddon, InputGroup } from "./ui/input";
49import { Card } from "./ui/card";
50
51const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = {
52 all: <LayoutGrid size={15} />,
53 code: <Code2 size={15} />,
54 issues: <ListChecks size={15} />,
55 chat: <MessagesSquare size={15} />,
56 calendar: <CalendarDays size={15} />,
57 docs: <BookOpen size={15} />,
58 monitoring: <Siren size={15} />,
59 cloud: <Cloud size={15} />,
60 ai: <Bot size={15} />,
61 data: <BarChart3 size={15} />,
62 crm: <Headset size={15} />,
63 design: <Palette size={15} />,
64 security: <ShieldCheck size={15} />,
65 files: <FolderOpen size={15} />,
66};
67
68const PROVIDER_COLORS: Record<Provider, string> = {
69 sentry: "#a78bfa",
70 datadog: "#a26ce6",
71 webhook: "#5eead4",
72 jira: "#5b8def",
73 linear: "#8b8ff0",
74};
75
76/**
77 * A mark's tile: a brand's icon, or letters or an icon, on a wash of
78 * `color`. With no colour (a brand drawn in black) it is drawn in the text
79 * colour on a neutral wash, so it reads in both themes.
80 */
81function MarkTile({ color, size, children }: { color: string | null; size: number; children: ReactNode }) {
82 const tint = color ?? "var(--color-fg)";
83 return (
84 <span
85 aria-hidden="true"
86 className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight"
87 style={{
88 width: size,
89 height: size,
90 borderRadius: size * 0.28,
91 fontSize: size * (typeof children === "string" && children.length > 1 ? 0.34 : 0.42),
92 background: `color-mix(in srgb, ${tint} ${color ? 16 : 8}%, transparent)`,
93 // Toward the text colour, so the mark reads on the tile in both themes.
94 color: color ? `color-mix(in srgb, ${color} 60%, var(--color-fg))` : "var(--color-fg)",
95 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tint} ${color ? 30 : 14}%, transparent)`,
96 }}
97 >
98 {children}
99 </span>
100 );
101}
102
103/** A brand's icon, from Simple Icons, in the tile's colour. */
104function BrandGlyph({ icon, size }: { icon: BrandIcon; size: number }) {
105 return (
106 <svg viewBox="0 0 24 24" width={size * 0.5} height={size * 0.5} fill="currentColor">
107 <path d={icon.path} />
108 </svg>
109 );
110}
111
112/** A provider's brand icon, by its name, when Simple Icons has one. */
113function brandMark(name: string, size: number): ReactNode {
114 const brand = brandIcon(name.replace(/_/g, "-"));
115 if (!brand) return null;
116 return (
117 <MarkTile color={brand.color} size={size}>
118 <BrandGlyph icon={brand} size={size} />
119 </MarkTile>
120 );
121}
122
123/** An integration's mark: its brand's icon, the webhook icon, or its initials in its colour. */
124export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
125 if (provider === "webhook") {
126 return (
127 <MarkTile color={PROVIDER_COLORS.webhook} size={size}>
128 <Webhook size={size * 0.5} />
129 </MarkTile>
130 );
131 }
132 return (
133 brandMark(provider, size) ?? (
134 <MarkTile color={PROVIDER_COLORS[provider]} size={size}>
135 {monogram(PROVIDERS[provider].label)}
136 </MarkTile>
137 )
138 );
139}
140
141/** An AI provider's mark: its brand's icon, an icon for an endpoint, or its initials in its colour. */
142export function AiProviderMark({ provider, size = 32 }: { provider: AiProvider; size?: number }) {
143 const look = AI_PROVIDER_LOOKS[provider];
144 return (
145 brandMark(provider, size) ?? (
146 <MarkTile color={look.color} size={size}>
147 {isEndpoint(provider) ? <Bot size={size * 0.5} /> : look.mark || monogram(AI_PROVIDERS[provider].label)}
148 </MarkTile>
149 )
150 );
151}
152
153/** One provider to choose, as a compact tile that opens its form. */
154function ProviderTile({ to, mark, label, blurb, active }: { to: string; mark: ReactNode; label: string; blurb: string; active: boolean }) {
155 return (
156 <Link
157 to={to}
158 preventScrollReset
159 className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${
160 active ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
161 }`}
162 >
163 {mark}
164 <span className="min-w-0 grow">
165 <span className="block truncate text-sm font-medium">{label}</span>
166 <Hint label={blurb}>
167 <span className="block truncate text-xs text-muted">{blurb}</span>
168 </Hint>
169 </span>
170 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
171 </Link>
172 );
173}
174
175/** Integrations to choose from, as compact tiles that open their form on their section's page. */
176export function ProviderTiles({
177 providers,
178 slug,
179 adding,
180 blurb,
181}: {
182 providers: Provider[];
183 slug: string;
184 adding: Provider | null;
185 blurb: (provider: Provider) => string;
186}) {
187 return (
188 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
189 {providers.map((provider) => (
190 <ProviderTile
191 key={provider}
192 to={`/${slug}/-/${providerPage(PROVIDERS[provider].kind)}?add=${provider}#add`}
193 mark={<ProviderMark provider={provider} size={28} />}
194 label={PROVIDERS[provider].label}
195 blurb={blurb(provider)}
196 active={adding === provider}
197 />
198 ))}
199 </div>
200 );
201}
202
203/** AI providers to choose from, as compact tiles that open their form on the workspace's AI page. */
204export function AiProviderTiles({ providers, slug, adding }: { providers: AiProvider[]; slug: string; adding: AiProvider | null }) {
205 return (
206 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
207 {providers.map((provider) => (
208 <ProviderTile
209 key={provider}
210 to={`/${slug}/-/ai?add=${provider}#add`}
211 mark={<AiProviderMark provider={provider} size={28} />}
212 label={AI_PROVIDERS[provider].label}
213 blurb={AI_PROVIDER_LOOKS[provider].blurb}
214 active={adding === provider}
215 />
216 ))}
217 </div>
218 );
219}
220
221/** A connector's tile: its brand's icon, its provider's mark, or its letters on a steady colour. */
222export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) {
223 const brand = brandIcon(view.id);
224 if (brand) {
225 return (
226 <MarkTile color={brand.color} size={size}>
227 <BrandGlyph icon={brand} size={size} />
228 </MarkTile>
229 );
230 }
231 if (view.provider) return <ProviderMark provider={view.provider} size={size} />;
232 // Outgoing webhooks have no brand: the same webhook mark as incoming ones.
233 if (view.id === "webhooks") return <ProviderMark provider="webhook" size={size} />;
234 return (
235 <MarkTile color={tileColor(view.id)} size={size}>
236 {monogram(view.name)}
237 </MarkTile>
238 );
239}
240
241export type ConnectorDirectoryProps = {
242 scope: ConnectorScope;
243 views: ConnectorView[];
244 /** What is connected here, by connector id. */
245 connected: Record<string, ConnectedState>;
246 /** The workspace's slug on its page; null on your own. */
247 workspace: string | null;
248 /** Whether the viewer can connect things here (an owner, or anyone for themselves). */
249 canManage: boolean;
250 /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */
251 nav?: "sidebar" | "chips";
252};
253
254/**
255 * The integrations directory: categories to browse, a search, and every
256 * connector as a card, sorted into Connected, Available and Soon.
257 */
258export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) {
259 const [params, setParams] = useSearchParams();
260 const category = categoryFilter(params.get("category"));
261 const [query, setQuery] = useState("");
262 const counts = categoryCounts(views);
263 const categories = categoriesWith(views);
264 const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) });
265 const searching = query.trim().length > 0;
266 const grouped = category === "all" && !searching;
267 const empty = found.connected.length + found.available.length + found.soon.length === 0;
268
269 const choose = (next: CategoryFilter) => {
270 setParams(
271 (current) => {
272 const updated = new URLSearchParams(current);
273 if (next === "all") updated.delete("category");
274 else updated.set("category", next);
275 return updated;
276 },
277 { replace: true, preventScrollReset: true },
278 );
279 setQuery("");
280 };
281
282 const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories];
283 const card = (view: ConnectorView) => (
284 <ConnectorCard
285 key={view.id}
286 view={view}
287 scope={scope}
288 state={connected[view.id] ?? null}
289 workspace={workspace}
290 canManage={canManage}
291 showCategory={category === "all" || searching}
292 />
293 );
294
295 return (
296 <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}>
297 {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */}
298 <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}>
299 <ul
300 className={cn(
301 "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 scrollbar-none sm:mx-0 sm:px-0",
302 nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0",
303 )}
304 >
305 {options.map((option) => {
306 const current = option.id === category;
307 return (
308 <li key={option.id} className="shrink-0">
309 <button
310 type="button"
311 onClick={() => choose(option.id)}
312 aria-current={current ? "page" : undefined}
313 className={cn(
314 "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors",
315 nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5",
316 current
317 ? "border-accent/40 bg-accent/10 text-fg md:bg-raised"
318 : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface",
319 )}
320 >
321 <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}>{CATEGORY_ICONS[option.id]}</span>
322 <span className="grow text-left">{option.title}</span>
323 <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span>
324 </button>
325 </li>
326 );
327 })}
328 </ul>
329 </nav>
330
331 <div className="mt-5 min-w-0 md:mt-0">
332 <InputGroup>
333 <InputAddon className="pr-0">
334 <Search size={15} />
335 </InputAddon>
336 <Input
337 type="search"
338 value={query}
339 onChange={(event) => setQuery(event.target.value)}
340 placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`}
341 aria-label="Search integrations"
342 />
343 </InputGroup>
344
345 {empty && (
346 <Card asChild tone="plain" className="mt-8 border-dashed px-4 py-8 text-center text-sm text-muted">
347 <p>
348 Nothing matches “{query.trim()}”.{" "}
349 <a
350 href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })}
351 className="text-accent hover:underline"
352 >
353 Ask for it
354 </a>
355 , and say what you would use it for.
356 </p>
357 </Card>
358 )}
359
360 {found.connected.length > 0 && (
361 <Group title="Connected" count={found.connected.length}>
362 <Cards>{found.connected.map(card)}</Cards>
363 </Group>
364 )}
365 {found.available.length > 0 && (
366 <Group
367 title="Available"
368 count={found.available.length}
369 about={
370 scope === "workspace" ? (canManage ? "Ready to connect now." : `Ready now. An owner of ${workspace} connects them.`) : "Ready to connect now."
371 }
372 >
373 <Cards>{found.available.map(card)}</Cards>
374 </Group>
375 )}
376 {found.soon.length > 0 && (
377 <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner.">
378 {grouped ? (
379 <div className="space-y-6">
380 {groupByCategory(found.soon).map((group) => (
381 <div key={group.id}>
382 <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase">
383 {CATEGORY_ICONS[group.id]}
384 {group.title}
385 </p>
386 <Cards>
387 {group.views.map((view) => (
388 <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />
389 ))}
390 </Cards>
391 </div>
392 ))}
393 </div>
394 ) : (
395 <Cards>{found.soon.map(card)}</Cards>
396 )}
397 </Group>
398 )}
399 </div>
400 </div>
401 );
402}
403
404function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) {
405 return (
406 <section className="mt-8">
407 <h2 className="flex items-baseline gap-2 text-sm font-medium">
408 {title}
409 <span className="text-xs font-normal text-faint tabular-nums">{count}</span>
410 </h2>
411 {about && <p className="mt-0.5 text-xs text-muted">{about}</p>}
412 <div className="mt-3">{children}</div>
413 </section>
414 );
415}
416
417function Cards({ children }: { children: ReactNode }) {
418 return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>;
419}
420
421function ConnectorCard({
422 view,
423 scope,
424 state,
425 workspace,
426 canManage,
427 showCategory,
428}: {
429 view: ConnectorView;
430 scope: ConnectorScope;
431 state: ConnectedState | null;
432 workspace: string | null;
433 canManage: boolean;
434 showCategory: boolean;
435}) {
436 const soon = view.status === "soon";
437 const setup = view.href ? connectorPath(view.href, workspace ?? "") : null;
438 const to = state?.manage ?? setup;
439 const also =
440 view.alsoIn === "personal"
441 ? "Each person can also connect their own, under their settings."
442 : view.alsoIn === "workspace"
443 ? "A workspace owner can also connect it for everyone."
444 : null;
445 return (
446 <li
447 className={cn(
448 "relative flex flex-col rounded-xl border p-4 transition-colors",
449 soon ? "border-line/70 bg-surface/40" : "border-line bg-surface",
450 !soon && to && "hover:border-line-strong",
451 )}
452 >
453 <div className="flex items-start gap-3">
454 <span className={cn(soon && "opacity-70 grayscale-35")}>
455 <ConnectorMark view={view} />
456 </span>
457 <div className="min-w-0 grow">
458 <div className="flex items-center gap-2">
459 {!soon && to ? (
460 // The whole card opens it: the link stretches over the card.
461 <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none">
462 {view.name}
463 </Link>
464 ) : (
465 <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p>
466 )}
467 {state ? (
468 state.problem ? (
469 <Hint label={state.problem}>
470 <Badge tone="warn" className="relative z-10" tabIndex={0}>
471 <AlertTriangle size={11} /> Needs attention
472 </Badge>
473 </Hint>
474 ) : (
475 <Badge tone="success">Connected</Badge>
476 )
477 ) : soon ? (
478 <Badge>Soon</Badge>
479 ) : null}
480 </div>
481 {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>}
482 </div>
483 </div>
484 <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p>
485 {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>}
486 {view.capabilities.length > 0 && (
487 <ul className="mt-3 flex flex-wrap gap-1.5">
488 {view.capabilities.map((capability) => (
489 <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted">
490 {capability}
491 </li>
492 ))}
493 </ul>
494 )}
495 <div className="mt-auto flex items-end justify-between gap-3 pt-3">
496 <p className="text-[0.6875rem] text-faint">{also}</p>
497 {soon ? (
498 <Button asChild variant="outline" size="xs" className="relative z-10">
499 <a href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}>Ask for this</a>
500 </Button>
501 ) : to ? (
502 <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent">
503 {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"}
504 <ChevronRight size={13} />
505 </span>
506 ) : null}
507 </div>
508 </li>
509 );
510}
511
512/** A short line saying who a page's connections are for, and where the other kind lives. */
513export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) {
514 return (
515 <Card asChild tone="plain" className="mb-6 bg-surface/60 px-4 py-3 text-sm text-muted">
516 <p>
517 {scope === "workspace" ? (
518 <>
519 Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>. Your own accounts, such as your calendar, are under{" "}
520 <Link to="/settings/integrations" className="text-accent hover:underline">
521 your integrations
522 </Link>
523 .
524 </>
525 ) : (
526 <>
527 Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools the whole team shares are
528 connected by a workspace owner, under the workspace's Integrations.
529 </>
530 )}
531 </p>
532 </Card>
533 );
534}