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