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