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