Skip to content
491 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 Provider, PROVIDERS } from "@g1t/contracts";
25import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
26
27import { AI_PROVIDERS } 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 { integrationsSection } 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: Partial<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 mark: its brand's icon, an icon for the generic ones, or its initials in its colour. */
113export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
114 const brand = provider === "webhook" ? null : brandIcon(provider.replace(/_/g, "-"));
115 if (brand) {
116 return (
117 <MarkTile color={brand.color} size={size}>
118 <BrandGlyph icon={brand} size={size} />
119 </MarkTile>
120 );
121 }
122 const entry = AI_PROVIDERS[provider];
123 const content =
124 provider === "webhook" ? (
125 <Webhook size={size * 0.5} />
126 ) : provider.endsWith("_endpoint") ? (
127 <Bot size={size * 0.5} />
128 ) : (
129 entry?.mark || monogram(PROVIDERS[provider].label)
130 );
131 return (
132 <MarkTile color={entry?.color ?? PROVIDER_COLORS[provider] ?? "#a1a1aa"} size={size}>
133 {content}
134 </MarkTile>
135 );
136}
137
138/** Providers to choose from, as compact tiles that open their form. */
139export function ProviderTiles({
140 providers,
141 slug,
142 adding,
143 blurb,
144}: {
145 providers: Provider[];
146 slug: string;
147 adding: Provider | null;
148 blurb: (provider: Provider) => string;
149}) {
150 return (
151 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
152 {providers.map((provider) => (
153 <Link
154 key={provider}
155 to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}?add=${provider}#add`}
156 preventScrollReset
157 className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${
158 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
159 }`}
160 >
161 <ProviderMark provider={provider} size={28} />
162 <span className="min-w-0 grow">
163 <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span>
164 <Hint label={blurb(provider)}>
165 <span className="block truncate text-xs text-muted">{blurb(provider)}</span>
166 </Hint>
167 </span>
168 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
169 </Link>
170 ))}
171 </div>
172 );
173}
174
175/** A connector's tile: its brand's icon, its provider's mark, or its letters on a steady colour. */
176export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) {
177 const brand = brandIcon(view.id);
178 if (brand) {
179 return (
180 <MarkTile color={brand.color} size={size}>
181 <BrandGlyph icon={brand} size={size} />
182 </MarkTile>
183 );
184 }
185 if (view.provider) return <ProviderMark provider={view.provider} size={size} />;
186 // Outgoing webhooks have no brand: the same webhook mark as incoming ones.
187 if (view.id === "webhooks") return <ProviderMark provider="webhook" size={size} />;
188 return (
189 <MarkTile color={tileColor(view.id)} size={size}>
190 {monogram(view.name)}
191 </MarkTile>
192 );
193}
194
195export type ConnectorDirectoryProps = {
196 scope: ConnectorScope;
197 views: ConnectorView[];
198 /** What is connected here, by connector id. */
199 connected: Record<string, ConnectedState>;
200 /** The workspace's slug on its page; null on your own. */
201 workspace: string | null;
202 /** Whether the viewer can connect things here (an owner, or anyone for themselves). */
203 canManage: boolean;
204 /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */
205 nav?: "sidebar" | "chips";
206};
207
208/**
209 * The integrations directory: categories to browse, a search, and every
210 * connector as a card, sorted into Connected, Available and Soon.
211 */
212export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) {
213 const [params, setParams] = useSearchParams();
214 const category = categoryFilter(params.get("category"));
215 const [query, setQuery] = useState("");
216 const counts = categoryCounts(views);
217 const categories = categoriesWith(views);
218 const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) });
219 const searching = query.trim().length > 0;
220 const grouped = category === "all" && !searching;
221 const empty = found.connected.length + found.available.length + found.soon.length === 0;
222
223 const choose = (next: CategoryFilter) => {
224 setParams(
225 (current) => {
226 const updated = new URLSearchParams(current);
227 if (next === "all") updated.delete("category");
228 else updated.set("category", next);
229 return updated;
230 },
231 { replace: true, preventScrollReset: true },
232 );
233 setQuery("");
234 };
235
236 const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories];
237 const card = (view: ConnectorView) => (
238 <ConnectorCard
239 key={view.id}
240 view={view}
241 scope={scope}
242 state={connected[view.id] ?? null}
243 workspace={workspace}
244 canManage={canManage}
245 showCategory={category === "all" || searching}
246 />
247 );
248
249 return (
250 <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}>
251 {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */}
252 <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}>
253 <ul
254 className={cn(
255 "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0",
256 nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0",
257 )}
258 >
259 {options.map((option) => {
260 const current = option.id === category;
261 return (
262 <li key={option.id} className="shrink-0">
263 <button
264 type="button"
265 onClick={() => choose(option.id)}
266 aria-current={current ? "page" : undefined}
267 className={cn(
268 "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors",
269 nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5",
270 current
271 ? "border-accent/40 bg-accent/10 text-fg md:bg-raised"
272 : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface",
273 )}
274 >
275 <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}>
276 {CATEGORY_ICONS[option.id]}
277 </span>
278 <span className="grow text-left">{option.title}</span>
279 <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span>
280 </button>
281 </li>
282 );
283 })}
284 </ul>
285 </nav>
286
287 <div className="mt-5 min-w-0 md:mt-0">
288 <InputGroup>
289 <InputAddon className="pr-0">
290 <Search size={15} />
291 </InputAddon>
292 <Input
293 type="search"
294 value={query}
295 onChange={(event) => setQuery(event.target.value)}
296 placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`}
297 aria-label="Search integrations"
298 />
299 </InputGroup>
300
301 {empty && (
302 <Card asChild tone="plain" className="mt-8 border-dashed px-4 py-8 text-center text-sm text-muted">
303 <p>
304 Nothing matches “{query.trim()}”.{" "}
305 <a
306 href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })}
307 className="text-accent hover:underline"
308 >
309 Ask for it
310 </a>
311 , and say what you would use it for.
312 </p>
313 </Card>
314 )}
315
316 {found.connected.length > 0 && (
317 <Group title="Connected" count={found.connected.length}>
318 <Cards>{found.connected.map(card)}</Cards>
319 </Group>
320 )}
321 {found.available.length > 0 && (
322 <Group
323 title="Available"
324 count={found.available.length}
325 about={
326 scope === "workspace"
327 ? canManage
328 ? "Ready to connect now."
329 : `Ready now. An owner of ${workspace} connects them.`
330 : "Ready to connect now."
331 }
332 >
333 <Cards>{found.available.map(card)}</Cards>
334 </Group>
335 )}
336 {found.soon.length > 0 && (
337 <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner.">
338 {grouped ? (
339 <div className="space-y-6">
340 {groupByCategory(found.soon).map((group) => (
341 <div key={group.id}>
342 <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase">
343 {CATEGORY_ICONS[group.id]}
344 {group.title}
345 </p>
346 <Cards>{group.views.map((view) => <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />)}</Cards>
347 </div>
348 ))}
349 </div>
350 ) : (
351 <Cards>{found.soon.map(card)}</Cards>
352 )}
353 </Group>
354 )}
355 </div>
356 </div>
357 );
358}
359
360function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) {
361 return (
362 <section className="mt-8">
363 <h2 className="flex items-baseline gap-2 text-sm font-medium">
364 {title}
365 <span className="text-xs font-normal text-faint tabular-nums">{count}</span>
366 </h2>
367 {about && <p className="mt-0.5 text-xs text-muted">{about}</p>}
368 <div className="mt-3">{children}</div>
369 </section>
370 );
371}
372
373function Cards({ children }: { children: ReactNode }) {
374 return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>;
375}
376
377function ConnectorCard({
378 view,
379 scope,
380 state,
381 workspace,
382 canManage,
383 showCategory,
384}: {
385 view: ConnectorView;
386 scope: ConnectorScope;
387 state: ConnectedState | null;
388 workspace: string | null;
389 canManage: boolean;
390 showCategory: boolean;
391}) {
392 const soon = view.status === "soon";
393 const setup = view.href ? connectorPath(view.href, workspace ?? "") : null;
394 const to = state?.manage ?? setup;
395 const also =
396 view.alsoIn === "personal"
397 ? "Each person can also connect their own, under their settings."
398 : view.alsoIn === "workspace"
399 ? "A workspace owner can also connect it for everyone."
400 : null;
401 return (
402 <li
403 className={cn(
404 "relative flex flex-col rounded-xl border p-4 transition-colors",
405 soon ? "border-line/70 bg-surface/40" : "border-line bg-surface",
406 !soon && to && "hover:border-line-strong",
407 )}
408 >
409 <div className="flex items-start gap-3">
410 <span className={cn(soon && "opacity-70 grayscale-[35%]")}>
411 <ConnectorMark view={view} />
412 </span>
413 <div className="min-w-0 grow">
414 <div className="flex items-center gap-2">
415 {!soon && to ? (
416 // The whole card opens it: the link stretches over the card.
417 <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none">
418 {view.name}
419 </Link>
420 ) : (
421 <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p>
422 )}
423 {state ? (
424 state.problem ? (
425 <Hint label={state.problem}>
426 <Badge tone="warn" className="relative z-10" tabIndex={0}>
427 <AlertTriangle size={11} /> Needs attention
428 </Badge>
429 </Hint>
430 ) : (
431 <Badge tone="success">Connected</Badge>
432 )
433 ) : soon ? (
434 <Badge>Soon</Badge>
435 ) : null}
436 </div>
437 {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>}
438 </div>
439 </div>
440 <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p>
441 {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>}
442 {view.capabilities.length > 0 && (
443 <ul className="mt-3 flex flex-wrap gap-1.5">
444 {view.capabilities.map((capability) => (
445 <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted">
446 {capability}
447 </li>
448 ))}
449 </ul>
450 )}
451 <div className="mt-auto flex items-end justify-between gap-3 pt-3">
452 <p className="text-[0.6875rem] text-faint">{also}</p>
453 {soon ? (
454 <Button asChild variant="outline" size="xs" className="relative z-10">
455 <a href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}>Ask for this</a>
456 </Button>
457 ) : to ? (
458 <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent">
459 {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"}
460 <ChevronRight size={13} />
461 </span>
462 ) : null}
463 </div>
464 </li>
465 );
466}
467
468/** A short line saying who a page's connections are for, and where the other kind lives. */
469export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) {
470 return (
471 <Card asChild tone="plain" className="mb-6 bg-surface/60 px-4 py-3 text-sm text-muted">
472 <p>
473 {scope === "workspace" ? (
474 <>
475 Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>.
476 Your own accounts, such as your calendar, are under{" "}
477 <Link to="/settings/integrations" className="text-accent hover:underline">
478 your integrations
479 </Link>
480 .
481 </>
482 ) : (
483 <>
484 Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools
485 the whole team shares are connected by a workspace owner, under the workspace's Integrations.
486 </>
487 )}
488 </p>
489 </Card>
490 );
491}