| 1 | /** |
| 2 | * Integrations in the Marketplace, by who builds them: every connector is |
| 3 | * g1t's own (Official), so that section holds the ones connected, the ones |
| 4 | * a workspace can connect today, any this g1t can't connect (with why), |
| 5 | * those each person connects for themselves, and the ones the catalog |
| 6 | * lists as coming; Verified, Community and Internal are sections too, |
| 7 | * empty and saying so. Search, and filters by tier and availability, kept |
| 8 | * in the address. Each workspace one is set up on its own page under |
| 9 | * Workspace → Integrations; owners connect, anyone else asks. |
| 10 | */ |
| 11 | import { Search } from "lucide-react"; |
| 12 | import { useState } from "react"; |
| 13 | import { Link, useSearchParams } from "react-router"; |
| 14 | |
| 15 | import { CONNECTORS, connectorView, connectorsFor } from "@g1t/contracts/connectors"; |
| 16 | |
| 17 | import type { Route } from "./+types/integrations"; |
| 18 | import { IntegrationCard, IntegrationRow, ListingFilterBar, ListingLegend, SectionHead, TierBadge, TierEmpty, filterCounts } from "../../../components/marketplace"; |
| 19 | import { type IntegrationListing, comingListings, integrationListings, integrationMatches, passes, personalListings, readFilters, tiersShown } from "../../../lib/marketplace"; |
| 20 | import { loadConnected } from "../../../lib/marketplace.server"; |
| 21 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 22 | import { useMarketplace } from "./layout"; |
| 23 | |
| 24 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 25 | const viewer = requireUser(context, request); |
| 26 | const slug = params.owner.toLowerCase(); |
| 27 | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); |
| 28 | return await loadConnected(slug, viewer); |
| 29 | } |
| 30 | |
| 31 | const HEADINGS = { official: "From g1t", verified: "From verified publishers", community: "From the community", internal: "Built in this workspace" } as const; |
| 32 | |
| 33 | export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) { |
| 34 | const { slug, owner, username, requests } = useMarketplace(); |
| 35 | const [query, setQuery] = useState(""); |
| 36 | const [params] = useSearchParams(); |
| 37 | const filters = readFilters(params); |
| 38 | const views = connectorsFor("workspace"); |
| 39 | const personalViews = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view) => view != null); |
| 40 | const workspace = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable); |
| 41 | // What each person connects for themselves, today; and what is coming, for either. |
| 42 | const yours = personalListings(personalViews, slug, loaderData.unavailable); |
| 43 | const coming = comingListings( |
| 44 | views, |
| 45 | personalViews.filter((view) => !CONNECTORS.find((c) => c.id === view.id)?.scopes.includes("workspace")), |
| 46 | slug, |
| 47 | ); |
| 48 | const everything = [...workspace, ...yours, ...coming]; |
| 49 | const wanted = query.trim().toLowerCase(); |
| 50 | const keep = (listing: IntegrationListing) => integrationMatches(listing, query) && passes(listing, filters); |
| 51 | // A search or an availability filter can empty a tier; the tier filter alone only picks one. |
| 52 | const filtered = wanted !== "" || filters.availability !== "all"; |
| 53 | // A plain function, not a component made each render: that would remount the cards (and close a request's dialog). |
| 54 | const grid = (items: IntegrationListing[]) => ( |
| 55 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 56 | {items.map((listing) => ( |
| 57 | <IntegrationCard key={`${listing.scope}:${listing.ref}`} listing={listing} slug={slug} owner={owner} /> |
| 58 | ))} |
| 59 | </div> |
| 60 | ); |
| 61 | return ( |
| 62 | <div className="space-y-10"> |
| 63 | <div className="space-y-4"> |
| 64 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 65 | <label className="flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-sm focus-within:border-line-strong"> |
| 66 | <Search size={15} className="shrink-0 text-faint" /> |
| 67 | <input |
| 68 | value={query} |
| 69 | onChange={(event) => setQuery(event.target.value)} |
| 70 | placeholder="Find an integration" |
| 71 | aria-label="Find an integration" |
| 72 | autoComplete="off" |
| 73 | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" |
| 74 | /> |
| 75 | </label> |
| 76 | <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg"> |
| 77 | Workspace integrations settings |
| 78 | </Link> |
| 79 | </div> |
| 80 | <ListingFilterBar kind="integration" filters={filters} counts={filterCounts(everything)} /> |
| 81 | </div> |
| 82 | |
| 83 | {tiersShown(filters).map((tier) => { |
| 84 | const connected = workspace.filter((l) => l.tier === tier && l.availability === "added" && keep(l)); |
| 85 | const available = workspace.filter((l) => l.tier === tier && l.availability === "available" && keep(l)); |
| 86 | const blocked = workspace.filter((l) => l.tier === tier && l.availability === "unavailable" && keep(l)); |
| 87 | const own = yours.filter((l) => l.tier === tier && keep(l)); |
| 88 | const soon = coming.filter((l) => l.tier === tier && keep(l)); |
| 89 | const count = connected.length + available.length + blocked.length + own.length + soon.length; |
| 90 | return ( |
| 91 | <section key={tier} aria-labelledby={`tier-${tier}`} className="space-y-8"> |
| 92 | <SectionHead |
| 93 | id={`tier-${tier}`} |
| 94 | title={ |
| 95 | <> |
| 96 | <TierBadge tier={tier} /> |
| 97 | {HEADINGS[tier]} |
| 98 | </> |
| 99 | } |
| 100 | aside={`${count}`} |
| 101 | > |
| 102 | {tier === "official" && count > 0 ? "Every connector in the catalog is built and supported by g1t. Each says what it lets agents do." : null} |
| 103 | </SectionHead> |
| 104 | {count === 0 && <TierEmpty tier={tier} kind="integration" filtered={filtered} />} |
| 105 | {connected.length > 0 && ( |
| 106 | <div> |
| 107 | <SectionHead sub id={`${tier}-connected`} title="Connected" aside={`${connected.length}`} /> |
| 108 | {grid(connected)} |
| 109 | </div> |
| 110 | )} |
| 111 | {available.length > 0 && ( |
| 112 | <div> |
| 113 | <SectionHead sub id={`${tier}-available`} title="Available to connect" aside={`${available.length}`}> |
| 114 | Connected once for the whole workspace by an owner. |
| 115 | </SectionHead> |
| 116 | {grid(available)} |
| 117 | </div> |
| 118 | )} |
| 119 | {blocked.length > 0 && ( |
| 120 | <div> |
| 121 | <SectionHead sub id={`${tier}-unavailable`} title="Not available here" aside={`${blocked.length}`}> |
| 122 | This g1t lacks something these need. Each says what. |
| 123 | </SectionHead> |
| 124 | {grid(blocked)} |
| 125 | </div> |
| 126 | )} |
| 127 | {own.length > 0 && ( |
| 128 | <div> |
| 129 | <SectionHead sub id={`${tier}-yours`} title="Connected by each person" aside={`${own.length}`}> |
| 130 | Each person connects these for themselves, and agents use them only when that person asks. |
| 131 | </SectionHead> |
| 132 | {grid(own)} |
| 133 | </div> |
| 134 | )} |
| 135 | {soon.length > 0 && ( |
| 136 | <div> |
| 137 | <SectionHead sub id={`${tier}-soon`} title="Soon" aside={`${soon.length}`}> |
| 138 | Planned, not built. Nobody can connect these yet. |
| 139 | </SectionHead> |
| 140 | <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 141 | {soon.map((listing) => ( |
| 142 | <IntegrationRow key={listing.ref} listing={listing} /> |
| 143 | ))} |
| 144 | </ul> |
| 145 | </div> |
| 146 | )} |
| 147 | </section> |
| 148 | ); |
| 149 | })} |
| 150 | <ListingLegend kind="integration" /> |
| 151 | </div> |
| 152 | ); |
| 153 | } |