Every Marketplace listing says who builds it and whether you can add it: a tier (Official, Verified, Community, Internal) with what it means, and an availability (Available, Connected, Soon, or Not available here with why) that never looks like a button that doesn't work; Extensions and Integrations are grouped and filtered by both, each integration has a page with what it lets agents do today and what's Soon, and the marketplace guide says how.
15 files+1250−3280/15 viewed
| 22 | 22 | *Marketplace*. Its address is `g1t.sh/<workspace>/-/marketplace`. The | |
| 23 | 23 | Marketplace isn't in the dock itself. | |
| 24 | 24 | ||
| 25 | + | ## Who builds what, and whether you can add it | |
| 26 | + | ||
| 27 | + | Every listing, on every card, page and request, carries two labels: its | |
| 28 | + | **tier**, which says who stands behind it, and its **availability**, | |
| 29 | + | which says whether it can be added here, now. Hover or focus a label to | |
| 30 | + | read what it means. **Discover** explains both under **Who builds what**. | |
| 31 | + | ||
| 32 | + | | Tier | Colour | Who stands behind it | | |
| 33 | + | | --- | --- | --- | | |
| 34 | + | | **Official** | Lavender | g1t. Built and supported by g1t. Every integration is Official, as are g1t's own extensions. | | |
| 35 | + | | **Verified** | Green | A reviewed publisher. Its code and the scopes it asks for are checked before it is listed. | | |
| 36 | + | | **Community** | Amber | Anyone. Its pages run sandboxed on the user-content domain (g1tusercontent.com on g1t.sh), and its server code stays off until that sandbox is hardened. | | |
| 37 | + | | **Internal** | Blue | Your own people and agents. Promoted for your workspace only. | | |
| 38 | + | ||
| 39 | + | Today everything listed is Official. The **Extensions** and | |
| 40 | + | **Integrations** tabs still show a section for each tier, so a tier | |
| 41 | + | with nothing in it says so: *No verified publishers yet*, *No community | |
| 42 | + | extensions yet*, *Nothing built in this workspace yet*. | |
| 43 | + | ||
| 44 | + | | Availability | What it means | | |
| 45 | + | | --- | --- | | |
| 46 | + | | **Available** | It can be added now. Owners add it for everyone; anyone else asks an owner with **Request**. | | |
| 47 | + | | **Connected** or **Installed** | This workspace has it. An installed extension that was switched off says **Switched off**. | | |
| 48 | + | | **Soon** | Planned, not built yet. Nobody can add it until it is released, so its card is drawn dashed and muted, with nothing to press. Its page still shows what it will do. | | |
| 49 | + | | **Not available here** | This workspace or this g1t lacks something it needs, and the listing says what. For example, GitHub needs a GitHub App, which a self-hosted g1t adds before anyone can connect it. | | |
| 50 | + | ||
| 51 | + | ### Filter by tier and availability | |
| 52 | + | ||
| 53 | + | The **Extensions** and **Integrations** tabs have two rows of filters, | |
| 54 | + | **Who builds it** and **Availability**, each option with how many | |
| 55 | + | listings it has. A filter is kept in the address, such as | |
| 56 | + | `/<workspace>/-/marketplace/integrations?tier=official&availability=soon`, | |
| 57 | + | so a filtered list can be shared. | |
| 58 | + | ||
| 25 | 59 | ## Who can add things | |
| 26 | 60 | ||
| 27 | 61 | Every member of a workspace can browse the Marketplace. Only its | |
| ⋯ | |||
| 33 | 67 | ||
| 34 | 68 | | Tab | What it shows | | |
| 35 | 69 | | --- | --- | | |
| 36 | − | | **Discover** | Featured extensions, integrations with the connected ones first, starter kits, and connected systems. | | |
| 37 | − | | **Extensions** | Every extension listed: published ones first, then g1t's own that are coming, then connected systems, and how extensions are shared. | | |
| 38 | − | | **Integrations** | Connected integrations, the ones you can connect now, the ones each person connects for themselves, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. | | |
| 70 | + | | **Discover** | Featured extensions, who builds what, integrations with the connected ones first, starter kits, and connected systems. | | |
| 71 | + | | **Extensions** | Every extension, by tier: g1t's own (connected systems apart), then Verified, Community and Internal. Filters by tier and availability, and how extensions are shared. | | |
| 72 | + | | **Integrations** | Every integration, by tier. Under Official: connected ones, the ones you can connect now, any not available here, the ones each person connects for themselves, and the ones coming. Search by name, by what it does, or by a word such as *tracker*, and filter by tier and availability. | | |
| 39 | 73 | | **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. | | |
| 40 | 74 | ||
| 41 | 75 | ## Extensions | |
| ⋯ | |||
| 72 | 106 | CRM), **Engineering extras** (On-call, Helpdesk bridge) and **People and | |
| 73 | 107 | operations** (Recruiting, ERP bridge). They are on **Discover**, marked | |
| 74 | 108 | **Soon** until then. | |
| 75 | − | ||
| 76 | − | ### Who stands behind a listing | |
| 77 | − | ||
| 78 | − | | Tier | Who | | | |
| 79 | − | | --- | --- | --- | | |
| 80 | − | | **Official** | g1t | Built and supported by g1t. | | |
| 81 | − | | **Verified** | A reviewed publisher | Code and permissions are checked before it is listed. | | |
| 82 | − | | **Community** | Anyone | Shared from a public repository. Pages run sandboxed; server code runs on the publisher's own servers until hosted code has an isolated sandbox. | | |
| 83 | − | | **Internal** | Your workspace | Built by your own people and agents, seen only by your workspace. | | |
| 84 | 109 | ||
| 85 | 110 | ### What an extension's page tells you | |
| 86 | 111 | ||
| 87 | − | Before anything is installed, its page lists: | |
| 112 | + | Its header says its tier, who publishes it, its availability and its | |
| 113 | + | category, such as *Official · by g1t · Soon · Customers*. Before anything | |
| 114 | + | is installed, its page lists: | |
| 88 | 115 | ||
| 89 | 116 | - **What it adds**: its pages, the cards it shows in chat, the agent roles | |
| 90 | 117 | it brings, the tools agents get, and the notifications it sends. | |
| ⋯ | |||
| 142 | 169 | **Manage** opens it once it is connected. **Needs attention** means a | |
| 143 | 170 | connection's last check failed: hover it to see why. | |
| 144 | 171 | ||
| 172 | + | Each integration has its own page, | |
| 173 | + | `/<workspace>/-/marketplace/integrations/<id>`, opened from its card. Its | |
| 174 | + | header says its tier, publisher, availability and category, and **What | |
| 175 | + | it lets agents and people do** sets out, for each way it is connected | |
| 176 | + | (for the whole workspace, or for each person), what works **Today** and | |
| 177 | + | what is **Soon**. Linear, for example, lets agents read and write back | |
| 178 | + | for a workspace today, while each person's Linear inbox is Soon. When an | |
| 179 | + | integration is not available here, its page says why. | |
| 180 | + | ||
| 145 | 181 | Some integrations each person connects for themselves, such as a GitHub | |
| 146 | 182 | account or an MCP client. They are listed under **Connected by each | |
| 147 | 183 | person**, and **Connect yours** opens your own | |
| ⋯ | |||
| 153 | 189 | ## Ask an owner to add something | |
| 154 | 190 | ||
| 155 | 191 | 1. Choose **Request** on an extension or an integration. | |
| 156 | − | 2. Say why you want it, if you like, and choose **Send request**. | |
| 192 | + | 2. Check what you are asking for: the dialog shows its tier, who | |
| 193 | + | publishes it, and that it is available. | |
| 194 | + | 3. Say why you want it, if you like, and choose **Send request**. | |
| 157 | 195 | ||
| 158 | 196 | Every owner is notified. The button then reads **Requested**, and **Your | |
| 159 | 197 | requests** lists it until an owner answers; you are notified when one does. | |
| ⋯ | |||
| 173 | 211 | ## Apps | |
| 174 | 212 | ||
| 175 | 213 | What the workspace added from the Marketplace that you can use is under | |
| 176 | − | **Apps**: today, the integrations it connected. Pin the ones you want to | |
| 177 | − | your dock. See [Apps](/guides/workspaces/#apps). | |
| 214 | + | **Apps**: today, the integrations it connected. Each shows its tier, as | |
| 215 | + | in the Marketplace. Pin the ones you want to your dock. See [Apps](/guides/workspaces/#apps). | |
| 3 | 3 | import { Link, useFetcher, useFetchers } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { ConnectorMark } from "./connectors"; | |
| 6 | + | import { TierBadge } from "./marketplace"; | |
| 6 | 7 | import { Skeleton } from "./ui/skeleton"; | |
| 7 | 8 | import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps"; | |
| 8 | 9 | import { cn } from "../lib/cn"; | |
| ⋯ | |||
| 95 | 96 | </span> | |
| 96 | 97 | {card ? ( | |
| 97 | 98 | <span className="min-w-0"> | |
| 98 | − | <span className="block truncate text-sm font-medium text-fg">{app.name}</span> | |
| 99 | + | <span className="flex min-w-0 items-center gap-2"> | |
| 100 | + | <span className="truncate text-sm font-medium text-fg">{app.name}</span> | |
| 101 | + | <TierBadge tier={app.tier} focusable={false} /> | |
| 102 | + | </span> | |
| 99 | 103 | <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span> | |
| 100 | 104 | </span> | |
| 101 | 105 | ) : ( | |
| 102 | − | <span className="w-full min-w-0"> | |
| 103 | − | <span className="block truncate text-xs font-medium text-fg-soft">{app.name}</span> | |
| 104 | − | {!app.usable && <span className="block truncate text-[0.625rem] text-accent">Request access</span>} | |
| 106 | + | <span className="flex w-full min-w-0 flex-col items-center gap-1"> | |
| 107 | + | <span className="block w-full truncate text-xs font-medium text-fg-soft">{app.name}</span> | |
| 108 | + | {app.usable ? <TierBadge tier={app.tier} focusable={false} className="px-1 text-[0.5625rem] leading-3.5" /> : <span className="block truncate text-[0.625rem] text-accent">Request access</span>} | |
| 105 | 109 | </span> | |
| 106 | 110 | )} | |
| 107 | 111 | </> | |
| 1 | 1 | /** | |
| 2 | 2 | * The Marketplace's pieces (routes/workspace/marketplace/): listings for | |
| 3 | − | * extensions and integrations with what the workspace has of each, the | |
| 4 | − | * button that adds one (owners) or asks an owner to (everyone else), | |
| 5 | − | * starter kits, and a request as owners and askers see it. | |
| 3 | + | * extensions and integrations, each with who stands behind it (its tier) | |
| 4 | + | * and whether it can be added here (its availability), the button that | |
| 5 | + | * adds one (owners) or asks an owner to (everyone else), the filters and | |
| 6 | + | * the legend for both, starter kits, and a request as owners and askers | |
| 7 | + | * see it. | |
| 6 | 8 | */ | |
| 7 | − | import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react"; | |
| 9 | + | import { ArrowRight, Ban, Check, CircleAlert, Clock, Plus, Power, Send, X } from "lucide-react"; | |
| 8 | 10 | import { type ReactNode, useEffect, useState } from "react"; | |
| 9 | − | import { Link, useFetcher } from "react-router"; | |
| 11 | + | import { Link, useFetcher, useLocation, useSearchParams } from "react-router"; | |
| 10 | 12 | ||
| 11 | − | import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts"; | |
| 13 | + | import type { ExtensionManifest, InstallRequest, ListingKind, ListingTier } from "@g1t/contracts"; | |
| 14 | + | import { LISTING_TIERS } from "@g1t/contracts/marketplace"; | |
| 12 | 15 | ||
| 13 | 16 | import { ConnectorMark } from "./connectors"; | |
| 14 | 17 | import { Badge } from "./ui/badge"; | |
| 15 | 18 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 16 | 19 | import { Hint } from "./ui/hint"; | |
| 17 | 20 | import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui"; | |
| 18 | − | import { type ExtensionListing, type IntegrationListing, type StarterKit, TIERS, extensionPath, marketplacePath } from "../lib/marketplace"; | |
| 21 | + | import { | |
| 22 | + | AVAILABILITIES, | |
| 23 | + | AVAILABILITY, | |
| 24 | + | type Availability, | |
| 25 | + | type ExtensionListing, | |
| 26 | + | type IntegrationListing, | |
| 27 | + | type ListingFilters, | |
| 28 | + | type StarterKit, | |
| 29 | + | TIERS, | |
| 30 | + | addedWord, | |
| 31 | + | extensionPath, | |
| 32 | + | marketplacePath, | |
| 33 | + | } from "../lib/marketplace"; | |
| 19 | 34 | import { cn } from "../lib/cn"; | |
| 20 | 35 | ||
| 21 | 36 | const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors"; | |
| ⋯ | |||
| 24 | 39 | quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`, | |
| 25 | 40 | }; | |
| 26 | 41 | ||
| 27 | − | /** Who stands behind a listing: a small badge, with the sentence on hover. */ | |
| 28 | − | export function TierBadge({ tier }: { tier: ListingTier }) { | |
| 42 | + | /** Each tier's colour: Official the accent, Verified green, Community amber, Internal blue. */ | |
| 43 | + | const TIER_TONE: Record<ListingTier, string> = { | |
| 44 | + | official: "bg-accent/12 text-accent", | |
| 45 | + | verified: "bg-success/12 text-success", | |
| 46 | + | community: "bg-warn/12 text-warn", | |
| 47 | + | internal: "bg-info/12 text-info", | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * Who stands behind a listing: a small square badge in its tier's colour, | |
| 52 | + | * with the sentence on hover or focus. Inside a link, `focusable={false}` | |
| 53 | + | * keeps it from being a second stop for the keyboard. | |
| 54 | + | */ | |
| 55 | + | export function TierBadge({ tier, focusable = true, className }: { tier: ListingTier; focusable?: boolean; className?: string }) { | |
| 56 | + | return ( | |
| 57 | + | <Hint label={`${TIERS[tier].label}: ${TIERS[tier].about}`}> | |
| 58 | + | <span | |
| 59 | + | tabIndex={focusable ? 0 : undefined} | |
| 60 | + | className={cn( | |
| 61 | + | "inline-flex shrink-0 items-center rounded-[5px] px-1.5 py-px font-mono text-[0.625rem] leading-4 font-medium tracking-[0.08em] whitespace-nowrap uppercase outline-none focus-visible:ring-2 focus-visible:ring-accent", | |
| 62 | + | TIER_TONE[tier], | |
| 63 | + | className, | |
| 64 | + | )} | |
| 65 | + | > | |
| 66 | + | {TIERS[tier].label} | |
| 67 | + | </span> | |
| 68 | + | </Hint> | |
| 69 | + | ); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /** | |
| 73 | + | * Whether a listing can be added here: Available, Connected or Installed, | |
| 74 | + | * Soon, or Not available here, as an icon and a word (never a button), | |
| 75 | + | * with what it means, or why, on hover or focus. | |
| 76 | + | */ | |
| 77 | + | export function AvailabilityBadge({ | |
| 78 | + | availability, | |
| 79 | + | kind, | |
| 80 | + | why, | |
| 81 | + | off = false, | |
| 82 | + | hint, | |
| 83 | + | focusable = true, | |
| 84 | + | className, | |
| 85 | + | }: { | |
| 86 | + | availability: Availability; | |
| 87 | + | kind: ListingKind; | |
| 88 | + | /** Why it isn't available here. */ | |
| 89 | + | why?: string | null; | |
| 90 | + | /** An installed extension that was switched off. */ | |
| 91 | + | off?: boolean; | |
| 92 | + | /** A hint in place of the usual sentence. */ | |
| 93 | + | hint?: string; | |
| 94 | + | /** False inside a link, so it isn't a second stop for the keyboard. */ | |
| 95 | + | focusable?: boolean; | |
| 96 | + | className?: string; | |
| 97 | + | }) { | |
| 98 | + | const label = availability === "added" ? (off ? "Switched off" : addedWord(kind)) : AVAILABILITY[availability].label; | |
| 99 | + | const about = | |
| 100 | + | hint ?? | |
| 101 | + | (availability === "unavailable" && why | |
| 102 | + | ? why | |
| 103 | + | : availability === "added" | |
| 104 | + | ? off | |
| 105 | + | ? "Installed, and switched off: its token doesn't work and its page doesn't load until an owner switches it on." | |
| 106 | + | : `This workspace has it ${kind === "integration" ? "connected" : "installed"}.` | |
| 107 | + | : AVAILABILITY[availability].about); | |
| 108 | + | const icon = | |
| 109 | + | availability === "available" ? ( | |
| 110 | + | <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-success" /> | |
| 111 | + | ) : availability === "added" ? ( | |
| 112 | + | off ? ( | |
| 113 | + | <Power size={12} className="shrink-0" /> | |
| 114 | + | ) : ( | |
| 115 | + | <Check size={13} className="shrink-0" /> | |
| 116 | + | ) | |
| 117 | + | ) : availability === "soon" ? ( | |
| 118 | + | <Clock size={12} className="shrink-0" /> | |
| 119 | + | ) : ( | |
| 120 | + | <Ban size={12} className="shrink-0" /> | |
| 121 | + | ); | |
| 29 | 122 | return ( | |
| 30 | − | <Hint label={TIERS[tier].about}> | |
| 31 | − | <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 32 | − | <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge> | |
| 123 | + | <Hint label={about}> | |
| 124 | + | <span | |
| 125 | + | tabIndex={focusable ? 0 : undefined} | |
| 126 | + | className={cn( | |
| 127 | + | "inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded text-xs whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-accent", | |
| 128 | + | availability === "available" ? "text-fg-soft" : availability === "added" ? (off ? "text-warn" : "text-success") : "text-muted", | |
| 129 | + | className, | |
| 130 | + | )} | |
| 131 | + | > | |
| 132 | + | {icon} | |
| 133 | + | <span className="truncate">{label}</span> | |
| 33 | 134 | </span> | |
| 34 | 135 | </Hint> | |
| 35 | 136 | ); | |
| ⋯ | |||
| 37 | 138 | ||
| 38 | 139 | /** "Soon", on what is planned and not built. */ | |
| 39 | 140 | export function ComingBadge() { | |
| 40 | − | return <Badge tone="neutral">Soon</Badge>; | |
| 141 | + | return <AvailabilityBadge availability="soon" kind="extension" />; | |
| 41 | 142 | } | |
| 42 | 143 | ||
| 144 | + | /** | |
| 145 | + | * What the labels mean: who builds what (the four tiers) and whether a | |
| 146 | + | * listing can be added here. On Discover, and at the end of each tab. | |
| 147 | + | */ | |
| 148 | + | export function ListingLegend({ id = "legend", kind }: { id?: string; kind?: ListingKind }) { | |
| 149 | + | return ( | |
| 150 | + | <section aria-labelledby={id} className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> | |
| 151 | + | <h2 id={id} className="text-base font-semibold tracking-tight"> | |
| 152 | + | Who builds what | |
| 153 | + | </h2> | |
| 154 | + | <p className="mt-1 max-w-2xl text-sm text-muted">Every listing says who stands behind it, and whether it can be added here. Today, everything listed is g1t's own.</p> | |
| 155 | + | <ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 156 | + | {LISTING_TIERS.map((tier) => ( | |
| 157 | + | <li key={tier} className="rounded-xl border border-line bg-bg p-4"> | |
| 158 | + | <TierBadge tier={tier} /> | |
| 159 | + | <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p> | |
| 160 | + | </li> | |
| 161 | + | ))} | |
| 162 | + | </ul> | |
| 163 | + | <h3 className="mt-6 text-sm font-semibold">Whether you can add it</h3> | |
| 164 | + | <ul className="mt-3 grid gap-x-6 gap-y-2.5 sm:grid-cols-2"> | |
| 165 | + | {AVAILABILITIES.map((availability) => ( | |
| 166 | + | <li key={availability} className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[0.8125rem] text-muted"> | |
| 167 | + | <span className="w-36 shrink-0"> | |
| 168 | + | <AvailabilityBadge availability={availability} kind={kind ?? "integration"} /> | |
| 169 | + | </span> | |
| 170 | + | <span className="min-w-0 basis-56 grow"> | |
| 171 | + | {availability === "added" | |
| 172 | + | ? kind === "extension" | |
| 173 | + | ? "Installed in this workspace." | |
| 174 | + | : kind === "integration" | |
| 175 | + | ? "Connected for this workspace." | |
| 176 | + | : "Connected or installed in this workspace." | |
| 177 | + | : availability === "unavailable" | |
| 178 | + | ? "This workspace or this g1t lacks something it needs, and the listing says what." | |
| 179 | + | : AVAILABILITY[availability].about} | |
| 180 | + | </span> | |
| 181 | + | </li> | |
| 182 | + | ))} | |
| 183 | + | </ul> | |
| 184 | + | </section> | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /** A tier's section while nothing in it is listed: said plainly, so the structure shows. */ | |
| 189 | + | export function TierEmpty({ tier, kind, filtered = false }: { tier: ListingTier; kind: ListingKind; filtered?: boolean }) { | |
| 190 | + | return ( | |
| 191 | + | <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted"> | |
| 192 | + | <span>{filtered && tier === "official" ? TIERS.official.none[kind] : filtered ? `None match. ${TIERS[tier].none[kind]}` : TIERS[tier].none[kind]}</span> | |
| 193 | + | </p> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * Filters by tier and by availability, kept in the address | |
| 199 | + | * (`?tier=verified&availability=soon`) so a filtered page can be shared. | |
| 200 | + | * Each option shows how many listings it has before filtering. | |
| 201 | + | */ | |
| 202 | + | export function ListingFilterBar({ | |
| 203 | + | kind, | |
| 204 | + | filters, | |
| 205 | + | counts, | |
| 206 | + | }: { | |
| 207 | + | kind: ListingKind; | |
| 208 | + | filters: ListingFilters; | |
| 209 | + | counts: { tier: Record<ListingTier, number>; availability: Record<Availability, number>; all: number }; | |
| 210 | + | }) { | |
| 211 | + | const [params] = useSearchParams(); | |
| 212 | + | const { pathname } = useLocation(); | |
| 213 | + | const href = (key: "tier" | "availability", value: string) => { | |
| 214 | + | const next = new URLSearchParams(params); | |
| 215 | + | if (value === "all") next.delete(key); | |
| 216 | + | else next.set(key, value); | |
| 217 | + | const query = next.toString(); | |
| 218 | + | return query ? `${pathname}?${query}` : pathname; | |
| 219 | + | }; | |
| 220 | + | const Option = ({ group, value, current, count, children }: { group: "tier" | "availability"; value: string; current: string; count: number; children: ReactNode }) => ( | |
| 221 | + | <Link | |
| 222 | + | to={href(group, value)} | |
| 223 | + | replace | |
| 224 | + | preventScrollReset | |
| 225 | + | aria-current={current === value ? "true" : undefined} | |
| 226 | + | className={cn( | |
| 227 | + | "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent", | |
| 228 | + | current === value ? "border-line-strong bg-raised font-medium text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg", | |
| 229 | + | )} | |
| 230 | + | > | |
| 231 | + | {children} | |
| 232 | + | <span className={cn("tabular-nums", current === value ? "text-muted" : "text-faint")}>{count}</span> | |
| 233 | + | </Link> | |
| 234 | + | ); | |
| 235 | + | return ( | |
| 236 | + | <div className="grid gap-2.5" role="group" aria-label={`Filter ${kind === "extension" ? "extensions" : "integrations"}`}> | |
| 237 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 238 | + | <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Who builds it</span> | |
| 239 | + | <Option group="tier" value="all" current={filters.tier} count={counts.all}> | |
| 240 | + | All | |
| 241 | + | </Option> | |
| 242 | + | {LISTING_TIERS.map((tier) => ( | |
| 243 | + | <Option key={tier} group="tier" value={tier} current={filters.tier} count={counts.tier[tier]}> | |
| 244 | + | <span aria-hidden="true" className={cn("size-1.5 rounded-full", TIER_DOT[tier])} /> | |
| 245 | + | {TIERS[tier].label} | |
| 246 | + | </Option> | |
| 247 | + | ))} | |
| 248 | + | </div> | |
| 249 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 250 | + | <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Availability</span> | |
| 251 | + | <Option group="availability" value="all" current={filters.availability} count={counts.all}> | |
| 252 | + | All | |
| 253 | + | </Option> | |
| 254 | + | {AVAILABILITIES.map((availability) => ( | |
| 255 | + | <Option key={availability} group="availability" value={availability} current={filters.availability} count={counts.availability[availability]}> | |
| 256 | + | {availability === "added" ? addedWord(kind) : AVAILABILITY[availability].label} | |
| 257 | + | </Option> | |
| 258 | + | ))} | |
| 259 | + | </div> | |
| 260 | + | </div> | |
| 261 | + | ); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | const TIER_DOT: Record<ListingTier, string> = { official: "bg-accent", verified: "bg-success", community: "bg-warn", internal: "bg-info" }; | |
| 265 | + | ||
| 266 | + | /** How many listings each filter option has. */ | |
| 267 | + | export function filterCounts(listings: { tier: ListingTier; availability: Availability }[]) { | |
| 268 | + | const tier = Object.fromEntries(LISTING_TIERS.map((t) => [t, listings.filter((l) => l.tier === t).length])) as Record<ListingTier, number>; | |
| 269 | + | const availability = Object.fromEntries(AVAILABILITIES.map((a) => [a, listings.filter((l) => l.availability === a).length])) as Record<Availability, number>; | |
| 270 | + | return { tier, availability, all: listings.length }; | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /** | |
| 274 | + | * A listing page's header line: its tier, who publishes it, whether it can | |
| 275 | + | * be added here (`children`), and its category, each labelled. | |
| 276 | + | */ | |
| 277 | + | export function ListingFacts({ tier, publisher, category, children }: { tier: ListingTier; publisher: string; category: string; children: ReactNode }) { | |
| 278 | + | const Dot = () => ( | |
| 279 | + | <span aria-hidden="true" className="text-faint"> | |
| 280 | + | · | |
| 281 | + | </span> | |
| 282 | + | ); | |
| 283 | + | return ( | |
| 284 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm text-muted"> | |
| 285 | + | <TierBadge tier={tier} /> | |
| 286 | + | <span> | |
| 287 | + | by <span className="text-fg-soft">{publisher}</span> | |
| 288 | + | </span> | |
| 289 | + | <span className="inline-flex items-center gap-2.5"> | |
| 290 | + | <Dot /> | |
| 291 | + | {children} | |
| 292 | + | </span> | |
| 293 | + | <span className="inline-flex items-center gap-2.5"> | |
| 294 | + | <Dot /> | |
| 295 | + | {category} | |
| 296 | + | </span> | |
| 297 | + | </p> | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | ||
| 43 | 301 | /** A section's heading, with a count or a link at its end. */ | |
| 44 | − | export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) { | |
| 302 | + | export function SectionHead({ id, title, aside, sub = false, children }: { id: string; title: ReactNode; aside?: ReactNode; sub?: boolean; children?: ReactNode }) { | |
| 303 | + | const Heading = sub ? "h3" : "h2"; | |
| 45 | 304 | return ( | |
| 46 | 305 | <div className="mb-3"> | |
| 47 | 306 | <div className="flex items-baseline justify-between gap-3"> | |
| 48 | − | <h2 id={id} className="text-base font-semibold tracking-tight"> | |
| 307 | + | <Heading id={id} className={cn("flex items-center gap-2 font-semibold tracking-tight", sub ? "text-sm" : "text-base")}> | |
| 49 | 308 | {title} | |
| 50 | − | </h2> | |
| 309 | + | </Heading> | |
| 51 | 310 | {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>} | |
| 52 | 311 | </div> | |
| 53 | 312 | {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>} | |
| ⋯ | |||
| 65 | 324 | ); | |
| 66 | 325 | } | |
| 67 | 326 | ||
| 327 | + | /** What a request's dialog shows of the listing asked for: its mark, tier, publisher and availability. */ | |
| 328 | + | export type RequestedListing = { kind: ListingKind; tier: ListingTier; publisher: string; mark: ReactNode }; | |
| 329 | + | ||
| 68 | 330 | /** | |
| 69 | − | * Asking the workspace's owners to add something: a dialog with an | |
| 70 | − | * optional note, sent to the requests route. Once sent, it says so. | |
| 331 | + | * Asking the workspace's owners to add something: a dialog that says | |
| 332 | + | * exactly what is asked for (who builds it, and that it can be added), | |
| 333 | + | * with an optional note, sent to the requests route. Once sent, it says so. | |
| 71 | 334 | */ | |
| 72 | − | export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) { | |
| 335 | + | export function RequestButton({ | |
| 336 | + | slug, | |
| 337 | + | listing, | |
| 338 | + | name, | |
| 339 | + | requested, | |
| 340 | + | about, | |
| 341 | + | className, | |
| 342 | + | }: { | |
| 343 | + | slug: string; | |
| 344 | + | listing: string; | |
| 345 | + | name: string; | |
| 346 | + | requested: boolean; | |
| 347 | + | about?: RequestedListing; | |
| 348 | + | className?: string; | |
| 349 | + | }) { | |
| 73 | 350 | const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` }); | |
| 74 | 351 | const [open, setOpen] = useState(false); | |
| 75 | 352 | const sent = fetcher.state === "idle" && fetcher.data?.error === null; | |
| ⋯ | |||
| 100 | 377 | <DialogTitle>Ask to add {name}</DialogTitle> | |
| 101 | 378 | <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription> | |
| 102 | 379 | </DialogHeader> | |
| 380 | + | {about && ( | |
| 381 | + | <div className="flex items-center gap-3 rounded-lg border border-line bg-bg px-3 py-2.5"> | |
| 382 | + | {about.mark} | |
| 383 | + | <div className="min-w-0"> | |
| 384 | + | <p className="truncate text-sm font-medium">{name}</p> | |
| 385 | + | <p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> | |
| 386 | + | <TierBadge tier={about.tier} /> | |
| 387 | + | <span>by {about.publisher}</span> | |
| 388 | + | <span aria-hidden="true" className="text-faint"> | |
| 389 | + | · | |
| 390 | + | </span> | |
| 391 | + | <AvailabilityBadge availability="available" kind={about.kind} hint="An owner can add it as soon as they agree." /> | |
| 392 | + | </p> | |
| 393 | + | </div> | |
| 394 | + | </div> | |
| 395 | + | )} | |
| 103 | 396 | <input type="hidden" name="intent" value="request" /> | |
| 104 | 397 | <input type="hidden" name="listing" value={listing} /> | |
| 105 | 398 | <label className="grid gap-1.5 text-sm"> | |
| ⋯ | |||
| 131 | 424 | ); | |
| 132 | 425 | } | |
| 133 | 426 | ||
| 134 | − | /** What an owner or a member can do with an integration. */ | |
| 135 | − | export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) { | |
| 427 | + | /** | |
| 428 | + | * What an owner or a member can do with an integration: connect it, ask | |
| 429 | + | * for it, or manage it once connected; nothing while it is Soon or not | |
| 430 | + | * available here, where the availability says why instead. | |
| 431 | + | */ | |
| 432 | + | export function IntegrationAction({ listing, slug, owner, className }: { listing: IntegrationListing; slug: string; owner: boolean; className?: string }) { | |
| 433 | + | if (listing.availability === "soon" || listing.availability === "unavailable") return null; | |
| 434 | + | if (listing.scope === "personal") { | |
| 435 | + | return listing.href ? ( | |
| 436 | + | <Link to={listing.href} className={cn(ACTION.quiet, className)}> | |
| 437 | + | Connect yours | |
| 438 | + | </Link> | |
| 439 | + | ) : null; | |
| 440 | + | } | |
| 136 | 441 | if (listing.connected) { | |
| 137 | 442 | return listing.href ? ( | |
| 138 | − | <Link to={listing.href} className={ACTION.quiet}> | |
| 443 | + | <Link to={listing.href} className={cn(ACTION.quiet, className)}> | |
| 139 | 444 | Manage | |
| 140 | 445 | </Link> | |
| 141 | 446 | ) : null; | |
| 142 | 447 | } | |
| 143 | 448 | if (owner) { | |
| 144 | 449 | return listing.href ? ( | |
| 145 | − | <Link to={listing.href} className={ACTION.primary}> | |
| 450 | + | <Link to={listing.href} className={cn(ACTION.primary, className)}> | |
| 146 | 451 | <Plus size={14} /> | |
| 147 | 452 | Connect | |
| 148 | 453 | </Link> | |
| 149 | 454 | ) : null; | |
| 150 | 455 | } | |
| 151 | − | return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />; | |
| 456 | + | return ( | |
| 457 | + | <RequestButton | |
| 458 | + | slug={slug} | |
| 459 | + | listing={listing.ref} | |
| 460 | + | name={listing.view.name} | |
| 461 | + | requested={listing.requested} | |
| 462 | + | about={{ kind: "integration", tier: listing.tier, publisher: listing.publisher, mark: <ConnectorMark view={listing.view} size={32} /> }} | |
| 463 | + | className={className} | |
| 464 | + | /> | |
| 465 | + | ); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /** Whether a listing can't be added by anyone here: drawn muted and dashed, with nothing to press. */ | |
| 469 | + | function inert(availability: Availability): boolean { | |
| 470 | + | return availability === "soon" || availability === "unavailable"; | |
| 152 | 471 | } | |
| 153 | 472 | ||
| 154 | − | /** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */ | |
| 473 | + | /** The card every listing shares: a link to its page over the whole card, with its badges and actions above it. */ | |
| 474 | + | function ListingShell({ to, name, muted, children }: { to: string; name: string; muted: boolean; children: ReactNode }) { | |
| 475 | + | return ( | |
| 476 | + | <article | |
| 477 | + | className={cn( | |
| 478 | + | "relative flex flex-col rounded-xl border p-4 transition-colors", | |
| 479 | + | muted ? "border-dashed border-line-strong/70 bg-transparent hover:border-line-strong" : "border-line bg-surface hover:border-line-strong", | |
| 480 | + | )} | |
| 481 | + | > | |
| 482 | + | <Link to={to} prefetch="intent" aria-label={name} className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-accent" /> | |
| 483 | + | {children} | |
| 484 | + | </article> | |
| 485 | + | ); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | /** | |
| 489 | + | * An integration as a card: its mark, who builds it, what it does for | |
| 490 | + | * agents, whether the workspace can add it or has it, and the way to. | |
| 491 | + | */ | |
| 155 | 492 | export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) { | |
| 156 | 493 | const { view, connected } = listing; | |
| 494 | + | const muted = inert(listing.availability); | |
| 157 | 495 | return ( | |
| 158 | − | <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 496 | + | <ListingShell to={listing.path} name={view.name} muted={muted}> | |
| 159 | 497 | <div className="flex items-start gap-3"> | |
| 160 | − | <ConnectorMark view={view} size={40} /> | |
| 498 | + | <span className={cn("shrink-0", muted && "opacity-60 grayscale")}> | |
| 499 | + | <ConnectorMark view={view} size={40} /> | |
| 500 | + | </span> | |
| 161 | 501 | <div className="min-w-0 grow"> | |
| 162 | − | <h3 className="truncate text-sm font-semibold">{view.name}</h3> | |
| 502 | + | <div className="flex min-w-0 items-center justify-between gap-2"> | |
| 503 | + | <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{view.name}</h3> | |
| 504 | + | <span className="relative shrink-0"> | |
| 505 | + | <TierBadge tier={listing.tier} /> | |
| 506 | + | </span> | |
| 507 | + | </div> | |
| 163 | 508 | <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p> | |
| 164 | 509 | </div> | |
| 165 | 510 | </div> | |
| 166 | 511 | {view.capabilities.length > 0 && ( | |
| 167 | − | <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does"> | |
| 512 | + | <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label={muted ? "What it will do" : "What it does"}> | |
| 168 | 513 | {view.capabilities.map((capability) => ( | |
| 169 | − | <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted"> | |
| 514 | + | <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}> | |
| 170 | 515 | {capability} | |
| 171 | 516 | </li> | |
| 172 | 517 | ))} | |
| 173 | 518 | </ul> | |
| 174 | 519 | )} | |
| 175 | − | <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 520 | + | <div className="relative mt-4 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3"> | |
| 176 | 521 | <span className="flex min-w-0 items-center gap-1.5 text-xs"> | |
| 177 | − | {connected ? ( | |
| 178 | − | connected.problem ? ( | |
| 179 | − | <Hint label={connected.problem}> | |
| 180 | − | <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none"> | |
| 181 | − | <CircleAlert size={13} className="shrink-0" /> | |
| 182 | − | <span className="truncate">Needs attention</span> | |
| 183 | − | </span> | |
| 184 | − | </Hint> | |
| 185 | − | ) : ( | |
| 186 | − | <span className="inline-flex min-w-0 items-center gap-1 text-success"> | |
| 187 | − | <Check size={13} className="shrink-0" /> | |
| 188 | − | <span className="truncate">Connected</span> | |
| 522 | + | {connected?.problem ? ( | |
| 523 | + | <Hint label={connected.problem}> | |
| 524 | + | <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 525 | + | <CircleAlert size={13} className="shrink-0" /> | |
| 526 | + | <span className="truncate">Needs attention</span> | |
| 189 | 527 | </span> | |
| 190 | − | ) | |
| 528 | + | </Hint> | |
| 191 | 529 | ) : ( | |
| 192 | − | <span className="truncate text-faint">Not connected</span> | |
| 530 | + | <AvailabilityBadge | |
| 531 | + | availability={listing.availability} | |
| 532 | + | kind="integration" | |
| 533 | + | why={listing.why} | |
| 534 | + | hint={ | |
| 535 | + | listing.scope === "personal" && listing.availability === "available" | |
| 536 | + | ? "Each person connects their own. Agents use it only when that person asks." | |
| 537 | + | : listing.availability === "available" && !owner | |
| 538 | + | ? "An owner connects it for everyone. Ask one with Request." | |
| 539 | + | : undefined | |
| 540 | + | } | |
| 541 | + | /> | |
| 193 | 542 | )} | |
| 543 | + | {listing.scope === "personal" && <span className="truncate text-faint">· Each person's own</span>} | |
| 194 | 544 | {owner && listing.waiting > 0 && ( | |
| 195 | 545 | <> | |
| 196 | 546 | <span className="text-faint">·</span> | |
| ⋯ | |||
| 200 | 550 | </span> | |
| 201 | 551 | <IntegrationAction listing={listing} slug={slug} owner={owner} /> | |
| 202 | 552 | </div> | |
| 203 | − | </article> | |
| 553 | + | </ListingShell> | |
| 554 | + | ); | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /** | |
| 558 | + | * An integration as a compact row, for the long list of coming ones: | |
| 559 | + | * muted and dashed, its tier and Soon, and nothing to press but its page. | |
| 560 | + | */ | |
| 561 | + | export function IntegrationRow({ listing }: { listing: IntegrationListing }) { | |
| 562 | + | const { view } = listing; | |
| 563 | + | return ( | |
| 564 | + | <li className="relative flex items-center gap-3 rounded-lg border border-dashed border-line-strong/70 px-3 py-2.5 transition-colors hover:border-line-strong"> | |
| 565 | + | <Link to={listing.path} prefetch="intent" aria-label={view.name} className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-accent" /> | |
| 566 | + | <span className="shrink-0 opacity-60 grayscale"> | |
| 567 | + | <ConnectorMark view={view} size={28} /> | |
| 568 | + | </span> | |
| 569 | + | <span className="min-w-0 grow"> | |
| 570 | + | <span className="block truncate text-sm text-fg-soft">{view.name}</span> | |
| 571 | + | <span className="block truncate text-xs text-faint">{view.description}</span> | |
| 572 | + | </span> | |
| 573 | + | <span className="relative flex shrink-0 flex-col items-end gap-1"> | |
| 574 | + | <TierBadge tier={listing.tier} /> | |
| 575 | + | <AvailabilityBadge availability={listing.availability} kind="integration" why={listing.why} /> | |
| 576 | + | </span> | |
| 577 | + | </li> | |
| 204 | 578 | ); | |
| 205 | 579 | } | |
| 206 | 580 | ||
| ⋯ | |||
| 209 | 583 | return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />; | |
| 210 | 584 | } | |
| 211 | 585 | ||
| 212 | − | /** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */ | |
| 586 | + | /** What an owner or a member can do with an extension: install or ask, once it is published; nothing before, or once installed. */ | |
| 213 | 587 | export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) { | |
| 214 | 588 | const fetcher = useFetcher<{ error: string | null }>(); | |
| 215 | − | const { manifest, install } = listing; | |
| 216 | − | if (manifest.status !== "available") { | |
| 589 | + | const { manifest } = listing; | |
| 590 | + | if (listing.availability !== "available") return null; | |
| 591 | + | if (!owner) { | |
| 217 | 592 | return ( | |
| 218 | − | <Hint label="Not published yet. It can be installed from its first release."> | |
| 219 | − | <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}> | |
| 220 | − | Soon | |
| 221 | − | </span> | |
| 222 | − | </Hint> | |
| 593 | + | <RequestButton | |
| 594 | + | slug={slug} | |
| 595 | + | listing={listing.ref} | |
| 596 | + | name={manifest.name} | |
| 597 | + | requested={listing.requested} | |
| 598 | + | about={{ kind: "extension", tier: listing.tier, publisher: manifest.publisher.name, mark: <ExtensionMark manifest={manifest} size={32} /> }} | |
| 599 | + | className={className} | |
| 600 | + | /> | |
| 223 | 601 | ); | |
| 224 | 602 | } | |
| 225 | − | if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>; | |
| 226 | − | if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />; | |
| 227 | 603 | return ( | |
| 228 | 604 | <fetcher.Form method="post" action={marketplacePath(slug, "requests")}> | |
| 229 | 605 | <input type="hidden" name="intent" value="install" /> | |
| ⋯ | |||
| 236 | 612 | ); | |
| 237 | 613 | } | |
| 238 | 614 | ||
| 615 | + | /** An extension's availability, as its card and page show it. */ | |
| 616 | + | export function ExtensionAvailability({ listing, owner }: { listing: ExtensionListing; owner: boolean }) { | |
| 617 | + | return ( | |
| 618 | + | <AvailabilityBadge | |
| 619 | + | availability={listing.availability} | |
| 620 | + | kind="extension" | |
| 621 | + | off={listing.install ? !listing.install.enabled : false} | |
| 622 | + | hint={ | |
| 623 | + | listing.availability === "soon" | |
| 624 | + | ? "Not published yet. It can be installed from its first release." | |
| 625 | + | : listing.availability === "available" && !owner | |
| 626 | + | ? "An owner installs it for everyone. Ask one with Request." | |
| 627 | + | : listing.install?.enabled | |
| 628 | + | ? `Version ${listing.install.version} is installed.` | |
| 629 | + | : undefined | |
| 630 | + | } | |
| 631 | + | /> | |
| 632 | + | ); | |
| 633 | + | } | |
| 634 | + | ||
| 239 | 635 | /** Where an extension's data goes, in a few words for a card. */ | |
| 240 | 636 | function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string { | |
| 241 | 637 | // "the CRM you connect" reads, on a card, as "Works with your CRM". | |
| ⋯ | |||
| 243 | 639 | return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`; | |
| 244 | 640 | } | |
| 245 | 641 | ||
| 246 | − | /** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */ | |
| 642 | + | /** An extension as a card: its mark, publisher and tier, what it does, where its data goes, and whether it can be added. */ | |
| 247 | 643 | export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) { | |
| 248 | 644 | const { manifest } = listing; | |
| 645 | + | const muted = inert(listing.availability); | |
| 249 | 646 | return ( | |
| 250 | − | <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 647 | + | <ListingShell to={extensionPath(slug, manifest.id)} name={manifest.name} muted={muted}> | |
| 251 | 648 | <div className="flex items-start gap-3"> | |
| 252 | − | <ExtensionMark manifest={manifest} /> | |
| 649 | + | <span className={cn("shrink-0", muted && "opacity-60 grayscale")}> | |
| 650 | + | <ExtensionMark manifest={manifest} /> | |
| 651 | + | </span> | |
| 253 | 652 | <div className="min-w-0 grow"> | |
| 254 | − | <h3 className="truncate text-sm font-semibold"> | |
| 255 | − | <Link to={extensionPath(slug, manifest.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent"> | |
| 256 | − | {manifest.name} | |
| 257 | − | </Link> | |
| 258 | − | </h3> | |
| 653 | + | <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{manifest.name}</h3> | |
| 259 | 654 | <p className="truncate text-xs text-muted"> | |
| 260 | − | {manifest.publisher.name} · {manifest.category} | |
| 655 | + | by {manifest.publisher.name} · {manifest.category} | |
| 261 | 656 | </p> | |
| 262 | 657 | </div> | |
| 263 | − | <span className="relative"> | |
| 264 | − | <TierBadge tier={manifest.publisher.tier} /> | |
| 658 | + | <span className="relative shrink-0"> | |
| 659 | + | <TierBadge tier={listing.tier} /> | |
| 265 | 660 | </span> | |
| 266 | 661 | </div> | |
| 267 | 662 | <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p> | |
| 268 | − | <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 269 | − | <span className="min-w-0 truncate text-xs text-faint">{dataLine(manifest)}</span> | |
| 663 | + | <p className="mt-2 truncate text-xs text-faint">{dataLine(manifest)}</p> | |
| 664 | + | <div className="relative mt-3 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3"> | |
| 665 | + | <span className="flex min-w-0 items-center gap-1.5 text-xs"> | |
| 666 | + | <ExtensionAvailability listing={listing} owner={owner} /> | |
| 667 | + | {owner && listing.waiting > 0 && ( | |
| 668 | + | <> | |
| 669 | + | <span className="text-faint">·</span> | |
| 670 | + | <Waiting slug={slug} count={listing.waiting} /> | |
| 671 | + | </> | |
| 672 | + | )} | |
| 673 | + | </span> | |
| 270 | 674 | <ExtensionAction listing={listing} slug={slug} owner={owner} /> | |
| 271 | 675 | </div> | |
| 272 | − | </article> | |
| 676 | + | </ListingShell> | |
| 273 | 677 | ); | |
| 274 | 678 | } | |
| 275 | 679 | ||
| ⋯ | |||
| 338 | 742 | ||
| 339 | 743 | /** | |
| 340 | 744 | * A starter kit as a card: the extensions it brings, overlapped, its name | |
| 341 | − | * and what it is for. Installed together once every one is published; | |
| 342 | − | * until then it says Soon. | |
| 745 | + | * and what it is for, who builds them, and whether it can be added. | |
| 746 | + | * Installed together once every one is published; until then it is Soon, | |
| 747 | + | * drawn muted with nothing to press. | |
| 343 | 748 | */ | |
| 344 | 749 | export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) { | |
| 345 | 750 | const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available"); | |
| 751 | + | const tiers = [...new Set(extensions.map((extension) => extension.publisher.tier))]; | |
| 346 | 752 | return ( | |
| 347 | − | <article className="flex flex-col rounded-xl border border-line bg-surface p-4"> | |
| 348 | − | <div className="flex items-center"> | |
| 349 | − | {extensions.map((extension, index) => ( | |
| 350 | − | <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-surface", index > 0 && "-ml-2")}> | |
| 351 | − | <ExtensionMark manifest={extension} size={30} /> | |
| 352 | − | </span> | |
| 353 | − | ))} | |
| 354 | − | </div> | |
| 355 | − | <h3 className="mt-3 text-sm font-semibold">{kit.name}</h3> | |
| 356 | − | <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p> | |
| 357 | − | <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 358 | − | <p className="min-w-0 truncate text-xs text-faint"> | |
| 753 | + | <article className={cn("flex flex-col rounded-xl border p-4", ready ? "border-line bg-surface" : "border-dashed border-line-strong/70")}> | |
| 754 | + | <div className="flex items-center justify-between gap-3"> | |
| 755 | + | <div className={cn("flex items-center", !ready && "opacity-60 grayscale")}> | |
| 359 | 756 | {extensions.map((extension, index) => ( | |
| 360 | − | <span key={extension.id}> | |
| 361 | − | {index > 0 && ", "} | |
| 362 | − | <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline"> | |
| 363 | − | {extension.name} | |
| 364 | − | </Link> | |
| 757 | + | <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-bg", index > 0 && "-ml-2")}> | |
| 758 | + | <ExtensionMark manifest={extension} size={30} /> | |
| 365 | 759 | </span> | |
| 366 | 760 | ))} | |
| 367 | − | </p> | |
| 368 | − | {!ready && ( | |
| 369 | − | <Hint label="Its extensions aren't all published yet. The kit installs them together once they are."> | |
| 370 | − | <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent")}> | |
| 371 | − | Soon | |
| 372 | − | </span> | |
| 373 | − | </Hint> | |
| 374 | − | )} | |
| 761 | + | </div> | |
| 762 | + | <span className="flex gap-1"> | |
| 763 | + | {tiers.map((tier) => ( | |
| 764 | + | <TierBadge key={tier} tier={tier} /> | |
| 765 | + | ))} | |
| 766 | + | </span> | |
| 767 | + | </div> | |
| 768 | + | <h3 className={cn("mt-3 text-sm font-semibold", !ready && "text-fg-soft")}>{kit.name}</h3> | |
| 769 | + | <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p> | |
| 770 | + | <p className="mt-2 truncate text-xs text-faint"> | |
| 771 | + | {extensions.map((extension, index) => ( | |
| 772 | + | <span key={extension.id}> | |
| 773 | + | {index > 0 && ", "} | |
| 774 | + | <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline"> | |
| 775 | + | {extension.name} | |
| 776 | + | </Link> | |
| 777 | + | </span> | |
| 778 | + | ))} | |
| 779 | + | </p> | |
| 780 | + | <div className="mt-3 flex min-h-8 items-center border-t border-line pt-3"> | |
| 781 | + | <AvailabilityBadge | |
| 782 | + | availability={ready ? "available" : "soon"} | |
| 783 | + | kind="extension" | |
| 784 | + | hint={ready ? undefined : "Its extensions aren't all published yet. The kit installs them together once they are."} | |
| 785 | + | /> | |
| 375 | 786 | </div> | |
| 376 | 787 | </article> | |
| 377 | 788 | ); | |
| 21 | 21 | * with the account carry over from it until the first change. | |
| 22 | 22 | * No Workers or React imports, so it can be tested under Node. | |
| 23 | 23 | */ | |
| 24 | − | import type { ExtensionInstall } from "@g1t/contracts"; | |
| 24 | + | import type { ExtensionInstall, ListingTier } from "@g1t/contracts"; | |
| 25 | 25 | import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors"; | |
| 26 | − | import { extensionById } from "@g1t/contracts/marketplace"; | |
| 26 | + | import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace"; | |
| 27 | 27 | ||
| 28 | 28 | /** The apps that are always in the dock. */ | |
| 29 | 29 | export type BuiltinApp = "today" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace"; | |
| ⋯ | |||
| 67 | 67 | about: string; | |
| 68 | 68 | /** The Marketplace listing it was installed from: `integration:sentry`. */ | |
| 69 | 69 | listing: string; | |
| 70 | + | /** Who stands behind it, as its Marketplace listing says: its publisher and tier. */ | |
| 71 | + | publisher: string; | |
| 72 | + | tier: ListingTier; | |
| 70 | 73 | /** The connector behind it, for its mark. */ | |
| 71 | 74 | connector: Pick<Connector, "id" | "name" | "provider">; | |
| 72 | 75 | /** Its page in the workspace `slug`. */ | |
| ⋯ | |||
| 111 | 114 | name: extension.name, | |
| 112 | 115 | about: extension.tagline, | |
| 113 | 116 | listing: `extension:${extension.id}`, | |
| 117 | + | publisher: extension.publisher.name, | |
| 118 | + | tier: extension.publisher.tier, | |
| 114 | 119 | connector: { id: extension.id, name: extension.name, provider: undefined }, | |
| 115 | 120 | // Its own page, in a sandboxed frame, is the extension host's to serve; until then, its listing. | |
| 116 | 121 | path: (slug) => `/${slug}/-/marketplace/extensions/${extension.id}`, | |
| ⋯ | |||
| 125 | 130 | name: connector.name, | |
| 126 | 131 | about: connector.description, | |
| 127 | 132 | listing: `integration:${connector.id}`, | |
| 133 | + | publisher: CONNECTOR_PUBLISHER.name, | |
| 134 | + | tier: CONNECTOR_PUBLISHER.tier, | |
| 128 | 135 | connector: { id: connector.id, name: connector.name, provider: connector.provider }, | |
| 129 | 136 | path: integrationPath(connector), | |
| 130 | 137 | usable: true, | |
| 16 | 16 | ||
| 17 | 17 | /** What the workspace `slug` has connected, as `viewer` may see it. Each source that does not answer counts as nothing connected. */ | |
| 18 | 18 | export async function connectedStates(slug: string, viewer: User): Promise<Record<string, ConnectedState>> { | |
| 19 | + | return (await workspaceConnections(slug, viewer)).connected; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * What the workspace `slug` has connected, and the connectors this g1t | |
| 24 | + | * can't connect at all, by id, each with why: GitHub when no GitHub App is | |
| 25 | + | * set up (as on a self-hosted g1t that hasn't added one). | |
| 26 | + | */ | |
| 27 | + | export async function workspaceConnections(slug: string, viewer: User): Promise<{ connected: Record<string, ConnectedState>; unavailable: Record<string, string> }> { | |
| 19 | 28 | const [connections, github, hooks] = await Promise.all([ | |
| 20 | 29 | integrations.list(slug, viewer).catch(() => null), | |
| 21 | 30 | githubApp.status(viewer, slug).catch(() => null), | |
| ⋯ | |||
| 47 | 56 | manage: null, | |
| 48 | 57 | }; | |
| 49 | 58 | } | |
| 50 | − | return connected; | |
| 59 | + | const unavailable: Record<string, string> = {}; | |
| 60 | + | if (github?.ok && !github.value.configured) unavailable.github = "This g1t has no GitHub App set up. Whoever runs it adds one first."; | |
| 61 | + | return { connected, unavailable }; | |
| 51 | 62 | } | |
| 1 | 1 | /** | |
| 2 | 2 | * What the Marketplace's pages read besides the layout's: what the | |
| 3 | − | * workspace has connected (lib/connected.server.ts). | |
| 3 | + | * workspace has connected, and what this g1t can't connect, with why | |
| 4 | + | * (lib/connected.server.ts). | |
| 4 | 5 | */ | |
| 5 | 6 | import type { User } from "@g1t/contracts"; | |
| 6 | 7 | ||
| 7 | 8 | import type { ConnectedState } from "./connectors"; | |
| 8 | − | import { connectedStates } from "./connected.server"; | |
| 9 | + | import { workspaceConnections } from "./connected.server"; | |
| 9 | 10 | ||
| 10 | − | export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> { | |
| 11 | − | return connectedStates(slug, viewer); | |
| 11 | + | export async function loadConnected(slug: string, viewer: User): Promise<{ connected: Record<string, ConnectedState>; unavailable: Record<string, string> }> { | |
| 12 | + | return workspaceConnections(slug, viewer); | |
| 12 | 13 | } |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InstallRequest } from "@g1t/contracts"; | |
| 5 | − | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 5 | + | import { connectorById, connectorsFor } from "@g1t/contracts/connectors"; | |
| 6 | 6 | ||
| 7 | 7 | import { FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace"; | |
| 8 | 8 | ||
| 9 | − | import { STARTER_KITS, addPath, comingIntegrations, extensionListings, integrationListings, integrationMatches, isConnectedSystem, marketplaceForm } from "./marketplace.ts"; | |
| 9 | + | import { | |
| 10 | + | STARTER_KITS, | |
| 11 | + | TIERS, | |
| 12 | + | addPath, | |
| 13 | + | categoryTitle, | |
| 14 | + | comingIntegrations, | |
| 15 | + | comingListings, | |
| 16 | + | extensionListings, | |
| 17 | + | integrationListings, | |
| 18 | + | integrationMatches, | |
| 19 | + | integrationUses, | |
| 20 | + | isConnectedSystem, | |
| 21 | + | marketplaceForm, | |
| 22 | + | passes, | |
| 23 | + | readFilters, | |
| 24 | + | tiersShown, | |
| 25 | + | } from "./marketplace.ts"; | |
| 10 | 26 | import { modeOf } from "./workspace-nav.ts"; | |
| 11 | 27 | ||
| 12 | 28 | const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({ | |
| ⋯ | |||
| 101 | 117 | assert.ok(FIRST_PARTY_EXTENSIONS.filter(isConnectedSystem).every((m) => m.bridges), "a connected system says what it bridges"); | |
| 102 | 118 | }); | |
| 103 | 119 | ||
| 120 | + | test("every listing says who builds it and whether it can be added here", () => { | |
| 121 | + | const views = connectorsFor("workspace"); | |
| 122 | + | const connected = { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } }; | |
| 123 | + | const listings = integrationListings(views, connected, [], "ana", "acme", { github: "This g1t has no GitHub App set up." }); | |
| 124 | + | assert.ok(listings.every((l) => l.tier === "official" && l.publisher === "g1t"), "the connector catalog is g1t's own"); | |
| 125 | + | assert.equal(listings.find((l) => l.view.id === "sentry")!.availability, "added"); | |
| 126 | + | assert.equal(listings.find((l) => l.view.id === "jira")!.availability, "available"); | |
| 127 | + | const github = listings.find((l) => l.view.id === "github")!; | |
| 128 | + | assert.equal(github.availability, "unavailable"); | |
| 129 | + | assert.equal(github.why, "This g1t has no GitHub App set up."); | |
| 130 | + | assert.equal(github.href, null, "nothing to connect where it can't be connected"); | |
| 131 | + | assert.equal(listings.find((l) => l.view.id === "linear")!.path, "/acme/-/marketplace/integrations/linear"); | |
| 132 | + | ||
| 133 | + | const coming = comingListings(views, connectorsFor("personal").filter((v) => v.id === "gmail"), "acme"); | |
| 134 | + | assert.ok(coming.every((l) => l.availability === "soon" && l.href === null && l.tier === "official")); | |
| 135 | + | assert.equal(coming.find((l) => l.view.id === "gmail")!.scope, "personal"); | |
| 136 | + | ||
| 137 | + | const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, [], [], "ana"); | |
| 138 | + | assert.ok(extensions.every((l) => l.tier === "official" && l.availability === "soon"), "g1t's own, none published yet"); | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("an integration's page says what it does today and what is still Soon", () => { | |
| 142 | + | const linear = integrationUses(connectorById("linear")!); | |
| 143 | + | assert.deepEqual(linear.today.map((u) => u.scope), ["workspace"]); | |
| 144 | + | assert.deepEqual(linear.today[0]!.capabilities, ["Agents can read", "Writes back"]); | |
| 145 | + | assert.deepEqual(linear.soon.map((u) => u.scope), ["personal"], "each person's Linear inbox is Soon"); | |
| 146 | + | const slack = integrationUses(connectorById("slack")!); | |
| 147 | + | assert.equal(slack.today.length, 0); | |
| 148 | + | assert.equal(slack.soon.length, 2); | |
| 149 | + | assert.equal(categoryTitle("issues"), "Issues & projects"); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | test("filters by tier and availability are read from the address, and unknown ones are All", () => { | |
| 153 | + | assert.deepEqual(readFilters(new URLSearchParams("tier=verified&availability=soon")), { tier: "verified", availability: "soon" }); | |
| 154 | + | assert.deepEqual(readFilters(new URLSearchParams("tier=gold&availability=maybe")), { tier: "all", availability: "all" }); | |
| 155 | + | assert.deepEqual(tiersShown({ tier: "all", availability: "all" }), ["official", "verified", "community", "internal"]); | |
| 156 | + | assert.deepEqual(tiersShown({ tier: "community", availability: "all" }), ["community"]); | |
| 157 | + | assert.ok(passes({ tier: "official", availability: "soon" }, { tier: "all", availability: "soon" })); | |
| 158 | + | assert.ok(!passes({ tier: "official", availability: "soon" }, { tier: "verified", availability: "all" })); | |
| 159 | + | for (const tier of ["verified", "community", "internal"] as const) assert.ok(TIERS[tier].none.extension.length > 0, `${tier} says when it is empty`); | |
| 160 | + | }); | |
| 161 | + | ||
| 104 | 162 | test("the Marketplace is part of Apps, not a mode of its own", () => { | |
| 105 | 163 | assert.equal(modeOf("/acme/-/marketplace", "acme"), "apps"); | |
| 106 | 164 | assert.equal(modeOf("/acme/-/marketplace/extensions/mail", "acme"), "apps"); | |
| 11 | 11 | * | |
| 12 | 12 | * No Workers or React imports, so it can be tested under Node. | |
| 13 | 13 | */ | |
| 14 | − | import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier } from "@g1t/contracts"; | |
| 15 | − | import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; | |
| 16 | − | import { listingRef, parseListing } from "@g1t/contracts/marketplace"; | |
| 14 | + | import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingKind, ListingTier } from "@g1t/contracts"; | |
| 15 | + | import { CONNECTOR_CATEGORIES, type Connector, type ConnectorCapability, type ConnectorScope, type ConnectorView, connectorPath, connectorView } from "@g1t/contracts/connectors"; | |
| 16 | + | import { CONNECTOR_PUBLISHER, LISTING_TIERS, listingRef, parseListing } from "@g1t/contracts/marketplace"; | |
| 17 | 17 | ||
| 18 | 18 | import type { ConnectedState } from "./connectors"; | |
| 19 | 19 | ||
| ⋯ | |||
| 22 | 22 | return `/${slug}/-/marketplace${page ? `/${page}` : ""}`; | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | − | /** Who stands behind a listing, in a word and a sentence. */ | |
| 26 | − | export const TIERS: Record<ListingTier, { label: string; about: string }> = { | |
| 27 | − | official: { label: "Official", about: "Built and supported by g1t." }, | |
| 28 | − | verified: { label: "Verified", about: "From a reviewed publisher, whose code and permissions are checked before it is listed." }, | |
| 29 | − | community: { label: "Community", about: "From anyone, shared from a public repository. Pages run sandboxed; server code waits for an isolated sandbox." }, | |
| 30 | − | internal: { label: "Internal", about: "Built by your own people and agents, and seen only by your workspace." }, | |
| 25 | + | /** | |
| 26 | + | * Who stands behind a listing: in a word, a sentence, and what a tier's | |
| 27 | + | * section says while nothing in it is listed (`none`, by kind). | |
| 28 | + | */ | |
| 29 | + | export const TIERS: Record<ListingTier, { label: string; about: string; none: Record<ListingKind, string> }> = { | |
| 30 | + | official: { | |
| 31 | + | label: "Official", | |
| 32 | + | about: "Built and supported by g1t.", | |
| 33 | + | none: { extension: "No official extensions match.", integration: "No official integrations match." }, | |
| 34 | + | }, | |
| 35 | + | verified: { | |
| 36 | + | label: "Verified", | |
| 37 | + | about: "From a reviewed publisher. Its code and the scopes it asks for are checked before it is listed.", | |
| 38 | + | none: { extension: "No verified publishers yet.", integration: "No verified publishers yet." }, | |
| 39 | + | }, | |
| 40 | + | community: { | |
| 41 | + | label: "Community", | |
| 42 | + | about: "From anyone. Its pages run sandboxed on the user-content domain, and its server code stays off until that sandbox is hardened.", | |
| 43 | + | none: { extension: "No community extensions yet.", integration: "No community integrations yet." }, | |
| 44 | + | }, | |
| 45 | + | internal: { | |
| 46 | + | label: "Internal", | |
| 47 | + | about: "Built by your own people and agents, and promoted for this workspace only.", | |
| 48 | + | none: { extension: "Nothing built in this workspace yet.", integration: "Nothing built in this workspace yet." }, | |
| 49 | + | }, | |
| 31 | 50 | }; | |
| 32 | 51 | ||
| 52 | + | /** | |
| 53 | + | * Whether a listing can be added here, now: it can (`available`), the | |
| 54 | + | * workspace has it (`added`: connected, or installed), it is planned and | |
| 55 | + | * can't be added by anyone yet (`soon`), or this workspace or this g1t | |
| 56 | + | * lacks something it needs (`unavailable`, with why). | |
| 57 | + | */ | |
| 58 | + | export type Availability = "available" | "added" | "soon" | "unavailable"; | |
| 59 | + | ||
| 60 | + | export const AVAILABILITIES: readonly Availability[] = ["available", "added", "soon", "unavailable"]; | |
| 61 | + | ||
| 62 | + | /** Each availability in words, and the sentence its hint shows. */ | |
| 63 | + | export const AVAILABILITY: Record<Availability, { label: string; about: string }> = { | |
| 64 | + | available: { label: "Available", about: "Can be added now. Owners add it for everyone; anyone else can ask an owner." }, | |
| 65 | + | added: { label: "Added", about: "This workspace has it." }, | |
| 66 | + | soon: { label: "Soon", about: "Planned, not built yet. Nobody can add it until it is released." }, | |
| 67 | + | unavailable: { label: "Not available here", about: "This workspace or this g1t lacks something it needs." }, | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | /** What a workspace that has a listing calls it: an integration is connected, an extension installed. */ | |
| 71 | + | export function addedWord(kind: ListingKind): string { | |
| 72 | + | return kind === "integration" ? "Connected" : "Installed"; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /** A filter's value: one tier or availability, or every one. */ | |
| 76 | + | export type ListingFilters = { tier: ListingTier | "all"; availability: Availability | "all" }; | |
| 77 | + | ||
| 78 | + | /** The filters a Marketplace page's address asks for (`?tier=verified&availability=soon`); anything unknown is All. */ | |
| 79 | + | export function readFilters(params: URLSearchParams): ListingFilters { | |
| 80 | + | const tier = params.get("tier"); | |
| 81 | + | const availability = params.get("availability"); | |
| 82 | + | return { | |
| 83 | + | tier: LISTING_TIERS.includes(tier as ListingTier) ? (tier as ListingTier) : "all", | |
| 84 | + | availability: AVAILABILITIES.includes(availability as Availability) ? (availability as Availability) : "all", | |
| 85 | + | }; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** Whether a listing passes the filters. */ | |
| 89 | + | export function passes(listing: { tier: ListingTier; availability: Availability }, filters: ListingFilters): boolean { | |
| 90 | + | return (filters.tier === "all" || listing.tier === filters.tier) && (filters.availability === "all" || listing.availability === filters.availability); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** The tiers a page shows sections for under `filters`: every one, or the one asked for. */ | |
| 94 | + | export function tiersShown(filters: ListingFilters): readonly ListingTier[] { | |
| 95 | + | return filters.tier === "all" ? LISTING_TIERS : [filters.tier]; | |
| 96 | + | } | |
| 97 | + | ||
| 33 | 98 | /** Whether `request` is `username`'s and still waiting. */ | |
| 34 | 99 | function openFor(request: InstallRequest, ref: string, username: string): boolean { | |
| 35 | 100 | return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase(); | |
| ⋯ | |||
| 40 | 105 | return requests.filter((request) => request.status === "open" && request.listing === ref).length; | |
| 41 | 106 | } | |
| 42 | 107 | ||
| 43 | − | /** An integration a workspace can connect, with whether it has. */ | |
| 108 | + | /** An integration in the Marketplace, with who stands behind it and whether the workspace has it. */ | |
| 44 | 109 | export type IntegrationListing = { | |
| 45 | 110 | ref: string; | |
| 46 | 111 | view: ConnectorView; | |
| 112 | + | /** Who connects it: an owner, once for the workspace, or each person for themselves. */ | |
| 113 | + | scope: ConnectorScope; | |
| 114 | + | /** Every connector is g1t's own (CONNECTOR_PUBLISHER). */ | |
| 115 | + | tier: ListingTier; | |
| 116 | + | publisher: string; | |
| 117 | + | availability: Availability; | |
| 118 | + | /** Why it can't be connected here, when it can't. */ | |
| 119 | + | why: string | null; | |
| 47 | 120 | /** How it is doing, when it is connected. */ | |
| 48 | 121 | connected: ConnectedState | null; | |
| 49 | 122 | /** Where an owner connects it, or manages it once connected. */ | |
| 50 | 123 | href: string | null; | |
| 124 | + | /** Its page in the Marketplace. */ | |
| 125 | + | path: string; | |
| 51 | 126 | requested: boolean; | |
| 52 | 127 | waiting: number; | |
| 53 | 128 | }; | |
| 54 | 129 | ||
| 130 | + | /** One connector as a listing in the workspace `slug`. */ | |
| 131 | + | function integrationListing( | |
| 132 | + | view: ConnectorView, | |
| 133 | + | scope: ConnectorScope, | |
| 134 | + | slug: string, | |
| 135 | + | connected: Record<string, ConnectedState> = {}, | |
| 136 | + | unavailable: Record<string, string> = {}, | |
| 137 | + | requests: InstallRequest[] = [], | |
| 138 | + | username = "", | |
| 139 | + | ): IntegrationListing { | |
| 140 | + | const ref = listingRef("integration", view.id); | |
| 141 | + | const state = scope === "workspace" ? (connected[view.id] ?? null) : null; | |
| 142 | + | const why = view.status === "available" && !state ? (unavailable[view.id] ?? null) : null; | |
| 143 | + | const availability: Availability = state ? "added" : view.status !== "available" ? "soon" : why ? "unavailable" : "available"; | |
| 144 | + | const setup = view.href && !why ? connectorPath(view.href, slug) : null; | |
| 145 | + | return { | |
| 146 | + | ref, | |
| 147 | + | view, | |
| 148 | + | scope, | |
| 149 | + | tier: CONNECTOR_PUBLISHER.tier, | |
| 150 | + | publisher: CONNECTOR_PUBLISHER.name, | |
| 151 | + | availability, | |
| 152 | + | why, | |
| 153 | + | connected: state, | |
| 154 | + | href: state?.manage ?? setup, | |
| 155 | + | path: integrationPath(slug, view.id), | |
| 156 | + | requested: requests.some((request) => openFor(request, ref, username)), | |
| 157 | + | waiting: openCount(requests, ref), | |
| 158 | + | }; | |
| 159 | + | } | |
| 160 | + | ||
| 55 | 161 | /** | |
| 56 | 162 | * The integrations a workspace can connect today, connected ones first, | |
| 57 | 163 | * each in catalog order. `connected` comes from the integrations service | |
| 58 | − | * and the GitHub App (lib/connected.server.ts). | |
| 164 | + | * and the GitHub App (lib/connected.server.ts), as does `unavailable`: | |
| 165 | + | * connectors this g1t can't connect, with why. | |
| 59 | 166 | */ | |
| 60 | 167 | export function integrationListings( | |
| 61 | 168 | views: ConnectorView[], | |
| ⋯ | |||
| 63 | 170 | requests: InstallRequest[], | |
| 64 | 171 | username: string, | |
| 65 | 172 | slug: string, | |
| 173 | + | unavailable: Record<string, string> = {}, | |
| 66 | 174 | ): IntegrationListing[] { | |
| 67 | − | const listings = views | |
| 68 | − | .filter((view) => view.status === "available") | |
| 69 | − | .map((view) => { | |
| 70 | − | const ref = listingRef("integration", view.id); | |
| 71 | − | const state = connected[view.id] ?? null; | |
| 72 | − | const setup = view.href ? connectorPath(view.href, slug) : null; | |
| 73 | − | return { | |
| 74 | − | ref, | |
| 75 | − | view, | |
| 76 | − | connected: state, | |
| 77 | − | href: state?.manage ?? setup, | |
| 78 | − | requested: requests.some((request) => openFor(request, ref, username)), | |
| 79 | − | waiting: openCount(requests, ref), | |
| 80 | − | }; | |
| 81 | − | }); | |
| 175 | + | const listings = views.filter((view) => view.status === "available").map((view) => integrationListing(view, "workspace", slug, connected, unavailable, requests, username)); | |
| 82 | 176 | return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)]; | |
| 83 | 177 | } | |
| 84 | 178 | ||
| 179 | + | /** What each person connects for themselves, available today, as listings. */ | |
| 180 | + | export function personalListings(views: ConnectorView[], slug: string, unavailable: Record<string, string> = {}): IntegrationListing[] { | |
| 181 | + | return views.filter((view) => view.status === "available" && view.href != null).map((view) => integrationListing(view, "personal", slug, {}, unavailable)); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /** The coming integrations (comingIntegrations), as listings: Soon, with nothing to press. */ | |
| 185 | + | export function comingListings(views: ConnectorView[], personal: ConnectorView[], slug: string): IntegrationListing[] { | |
| 186 | + | const workspace = new Set(views.map((view) => view.id)); | |
| 187 | + | return comingIntegrations(views, personal).map((view) => integrationListing(view, workspace.has(view.id) ? "workspace" : "personal", slug)); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** One integration's page in the Marketplace. */ | |
| 191 | + | export function integrationPath(slug: string, id: string): string { | |
| 192 | + | return `/${slug}/-/marketplace/integrations/${id}`; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /** One way an integration is connected, and what it does there. */ | |
| 196 | + | export type IntegrationUse = { | |
| 197 | + | scope: ConnectorScope; | |
| 198 | + | /** Who connects it this way, in words. */ | |
| 199 | + | who: string; | |
| 200 | + | description: string; | |
| 201 | + | capabilities: ConnectorCapability[]; | |
| 202 | + | }; | |
| 203 | + | ||
| 204 | + | /** | |
| 205 | + | * What an integration does, today and soon: each way it is connected (for | |
| 206 | + | * the workspace, for each person) under whether that way is available yet, | |
| 207 | + | * from the catalog. Linear, say, works for a workspace today, and for each | |
| 208 | + | * person's own inbox soon. | |
| 209 | + | */ | |
| 210 | + | export function integrationUses(connector: Connector): { today: IntegrationUse[]; soon: IntegrationUse[] } { | |
| 211 | + | const today: IntegrationUse[] = []; | |
| 212 | + | const soon: IntegrationUse[] = []; | |
| 213 | + | for (const scope of connector.scopes) { | |
| 214 | + | const view = connectorView(connector, scope); | |
| 215 | + | if (!view) continue; | |
| 216 | + | const use = { | |
| 217 | + | scope, | |
| 218 | + | who: scope === "workspace" ? "For the whole workspace, connected once by an owner" : "For each person, with their own account", | |
| 219 | + | description: view.description, | |
| 220 | + | capabilities: view.capabilities, | |
| 221 | + | }; | |
| 222 | + | (view.status === "available" ? today : soon).push(use); | |
| 223 | + | } | |
| 224 | + | return { today, soon }; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /** A connector category's title: `issues` is `Issues & projects`. */ | |
| 228 | + | export function categoryTitle(category: Connector["category"]): string { | |
| 229 | + | return CONNECTOR_CATEGORIES.find((c) => c.id === category)?.title ?? category; | |
| 230 | + | } | |
| 231 | + | ||
| 85 | 232 | /** | |
| 86 | 233 | * The integrations the catalog lists as coming: a workspace's, then those | |
| 87 | 234 | * each person connects for themselves (a calendar, a mailbox), once each. | |
| ⋯ | |||
| 93 | 240 | return [...workspace, ...own]; | |
| 94 | 241 | } | |
| 95 | 242 | ||
| 96 | − | /** An extension in the Marketplace, with whether the workspace has it. */ | |
| 243 | + | /** An extension in the Marketplace, with who stands behind it and whether the workspace has it. */ | |
| 97 | 244 | export type ExtensionListing = { | |
| 98 | 245 | ref: string; | |
| 99 | 246 | manifest: ExtensionManifest; | |
| 247 | + | /** Its publisher's tier. */ | |
| 248 | + | tier: ListingTier; | |
| 249 | + | availability: Availability; | |
| 100 | 250 | install: ExtensionInstall | null; | |
| 101 | 251 | requested: boolean; | |
| 102 | 252 | waiting: number; | |
| ⋯ | |||
| 126 | 276 | export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] { | |
| 127 | 277 | const listings = manifests.map((manifest) => { | |
| 128 | 278 | const ref = listingRef("extension", manifest.id); | |
| 279 | + | const install = installs.find((install) => install.listing === ref) ?? null; | |
| 129 | 280 | return { | |
| 130 | 281 | ref, | |
| 131 | 282 | manifest, | |
| 132 | − | install: installs.find((install) => install.listing === ref) ?? null, | |
| 283 | + | tier: manifest.publisher.tier, | |
| 284 | + | availability: (install ? "added" : manifest.status === "available" ? "available" : "soon") as Availability, | |
| 285 | + | install, | |
| 133 | 286 | requested: requests.some((request) => openFor(request, ref, username)), | |
| 134 | 287 | waiting: openCount(requests, ref), | |
| 135 | 288 | }; | |
| 218 | 218 | route("extensions", "routes/workspace/marketplace/extensions.tsx"), | |
| 219 | 219 | route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"), | |
| 220 | 220 | route("integrations", "routes/workspace/marketplace/integrations.tsx"), | |
| 221 | + | route("integrations/:integration", "routes/workspace/marketplace/integration.tsx"), | |
| 221 | 222 | route("requests", "routes/workspace/marketplace/requests.tsx"), | |
| 222 | 223 | ]), | |
| 223 | 224 | route("-/marketplace/agents", "routes/workspace/moved.ts", { id: "routes/workspace/moved-marketplace-agents" }), |
| 1 | 1 | /** | |
| 2 | − | * The Marketplace's front page: featured extensions, integrations | |
| 3 | − | * (connected first), starter kits, and connected systems. Every listing is | |
| 4 | − | * real: integrations a workspace can connect today, with their real state, | |
| 5 | − | * and g1t's own extensions marked Soon until their first release. | |
| 2 | + | * The Marketplace's front page: featured extensions, who builds what (the | |
| 3 | + | * tiers, and what each availability means), integrations (connected | |
| 4 | + | * first), starter kits, and connected systems. Every listing is real: | |
| 5 | + | * integrations a workspace can connect today, with their real state, and | |
| 6 | + | * g1t's own extensions marked Soon until their first release. Every one | |
| 7 | + | * says its tier and whether it can be added here. | |
| 6 | 8 | */ | |
| 7 | 9 | import { Link } from "react-router"; | |
| 8 | 10 | ||
| ⋯ | |||
| 10 | 12 | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 11 | 13 | ||
| 12 | 14 | import type { Route } from "./+types/discover"; | |
| 13 | − | import { ComingBadge, ExtensionCard, ExtensionMark, IntegrationCard, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace"; | |
| 15 | + | import { AvailabilityBadge, ExtensionCard, ExtensionMark, IntegrationCard, ListingLegend, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace"; | |
| 14 | 16 | import { STARTER_KITS, extensionListings, extensionPath, integrationListings, isConnectedSystem, marketplacePath } from "../../../lib/marketplace"; | |
| 15 | 17 | import { loadConnected } from "../../../lib/marketplace.server"; | |
| 16 | 18 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| ⋯ | |||
| 20 | 22 | const viewer = requireUser(context, request); | |
| 21 | 23 | const slug = params.owner.toLowerCase(); | |
| 22 | 24 | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); | |
| 23 | − | return { connected: await loadConnected(slug, viewer) }; | |
| 25 | + | return await loadConnected(slug, viewer); | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | /** How many integrations the front page shows before "All". */ | |
| ⋯ | |||
| 31 | 33 | export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) { | |
| 32 | 34 | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 33 | 35 | const asked = requests?.requests ?? []; | |
| 34 | − | const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug); | |
| 36 | + | const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug, loaderData.unavailable); | |
| 35 | 37 | const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username); | |
| 36 | 38 | const featured = FEATURED.map((id) => extensionById(id)).filter((manifest) => manifest != null); | |
| 37 | 39 | const bridges = extensions.filter((listing) => isConnectedSystem(listing.manifest)); | |
| ⋯ | |||
| 51 | 53 | </div> | |
| 52 | 54 | </section> | |
| 53 | 55 | ||
| 56 | + | <ListingLegend /> | |
| 57 | + | ||
| 54 | 58 | <section aria-labelledby="integrations"> | |
| 55 | 59 | <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All integrations</SeeAll>}> | |
| 56 | 60 | Connect what your team already uses, so agents can read it and act in it. Each lists, in plain words, what it lets agents do. | |
| ⋯ | |||
| 99 | 103 | <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_100%_0%,var(--color-accent)_0%,transparent_55%)] opacity-[0.12]" /> | |
| 100 | 104 | <div className="relative flex items-center gap-2"> | |
| 101 | 105 | <ExtensionMark manifest={manifest} size={large ? 36 : 28} /> | |
| 102 | − | <TierBadge tier={manifest.publisher.tier} /> | |
| 103 | − | {manifest.status !== "available" && <ComingBadge />} | |
| 106 | + | <TierBadge tier={manifest.publisher.tier} focusable={false} /> | |
| 107 | + | <span className="text-xs text-muted">by {manifest.publisher.name}</span> | |
| 108 | + | {manifest.status !== "available" && <AvailabilityBadge availability="soon" kind="extension" hint="Not published yet. It can be installed from its first release." focusable={false} className="ml-auto" />} | |
| 104 | 109 | </div> | |
| 105 | 110 | <h3 className={`relative mt-3 font-semibold tracking-tight ${large ? "text-2xl" : "text-base"}`}>{manifest.name}</h3> | |
| 106 | 111 | <p className={`relative mt-1 max-w-xl text-muted ${large ? "text-[0.9375rem]" : "line-clamp-2 text-[0.8125rem]"}`}>{large ? manifest.description : manifest.tagline}</p> | |
| 10 | 10 | import { dataDisclosure, extensionById } from "@g1t/contracts/marketplace"; | |
| 11 | 11 | ||
| 12 | 12 | import type { Route } from "./+types/extension"; | |
| 13 | − | import { ACTION, ComingBadge, ExtensionAction, ExtensionMark, TierBadge } from "../../../components/marketplace"; | |
| 13 | + | import { ACTION, ExtensionAction, ExtensionAvailability, ExtensionMark, ListingFacts } from "../../../components/marketplace"; | |
| 14 | 14 | import { SubmitButton } from "../../../components/ui"; | |
| 15 | − | import { extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace"; | |
| 15 | + | import { TIERS, extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace"; | |
| 16 | 16 | import { page } from "../../../lib/meta"; | |
| 17 | 17 | import { useMarketplace } from "./layout"; | |
| 18 | 18 | ||
| ⋯ | |||
| 56 | 56 | <header className="mt-5 flex flex-wrap items-center gap-4"> | |
| 57 | 57 | <ExtensionMark manifest={manifest} size={64} /> | |
| 58 | 58 | <div className="min-w-0 grow basis-60"> | |
| 59 | − | <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 60 | − | {manifest.name} | |
| 61 | − | <TierBadge tier={manifest.publisher.tier} /> | |
| 62 | − | {manifest.status !== "available" && <ComingBadge />} | |
| 63 | − | </h2> | |
| 64 | − | <p className="mt-0.5 text-sm text-muted"> | |
| 65 | − | {manifest.publisher.name} · {manifest.category} · Free | |
| 66 | − | </p> | |
| 59 | + | <h2 className="text-xl font-semibold tracking-tight">{manifest.name}</h2> | |
| 60 | + | <ListingFacts tier={listing!.tier} publisher={manifest.publisher.name} category={manifest.category}> | |
| 61 | + | <ExtensionAvailability listing={listing!} owner={owner} /> | |
| 62 | + | </ListingFacts> | |
| 67 | 63 | </div> | |
| 68 | 64 | <ExtensionAction listing={listing!} slug={slug} owner={owner} className="h-9 px-4 text-sm" /> | |
| 69 | 65 | </header> | |
| 66 | + | {listing!.availability === "soon" && ( | |
| 67 | + | <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted"> | |
| 68 | + | Not published yet: nobody can install it until its first release. This page shows what it will add and what it will be able to do. | |
| 69 | + | </p> | |
| 70 | + | )} | |
| 70 | 71 | <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{manifest.description}</p> | |
| 71 | 72 | ||
| 72 | 73 | <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| ⋯ | |||
| 114 | 115 | </section> | |
| 115 | 116 | <dl className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 116 | 117 | <div> | |
| 118 | + | <dt className="text-xs text-faint">Publisher</dt> | |
| 119 | + | <dd className="mt-0.5"> | |
| 120 | + | {manifest.publisher.name} · {TIERS[listing!.tier].label} | |
| 121 | + | <span className="block text-xs text-muted">{TIERS[listing!.tier].about}</span> | |
| 122 | + | </dd> | |
| 123 | + | </div> | |
| 124 | + | <div> | |
| 117 | 125 | <dt className="text-xs text-faint">Version</dt> | |
| 118 | 126 | <dd className="mt-0.5">{install ? `${install.version} installed` : (manifest.version ?? "Not published yet")}</dd> | |
| 119 | 127 | </div> | |
| 1 | 1 | /** | |
| 2 | 2 | * Extensions in the Marketplace: every listed extension, from its | |
| 3 | − | * manifest (@g1t/contracts marketplace.ts), published ones first, with what | |
| 4 | − | * the workspace installed; connected systems apart; then how extensions | |
| 5 | − | * are published and who stands behind each tier. | |
| 3 | + | * manifest (@g1t/contracts marketplace.ts), by who builds it (Official, | |
| 4 | + | * Verified, Community, Internal), each tier a section even while it has | |
| 5 | + | * nothing, so it is plain what is whose. Filters by tier and availability | |
| 6 | + | * live in the address. Then how extensions are shared, and the legend. | |
| 6 | 7 | */ | |
| 7 | − | import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace"; | |
| 8 | + | import { useSearchParams } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { ListingTier } from "@g1t/contracts"; | |
| 11 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 8 | 12 | ||
| 9 | − | import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace"; | |
| 10 | − | import { TIERS, extensionListings, isConnectedSystem } from "../../../lib/marketplace"; | |
| 13 | + | import { ExtensionCard, ListingFilterBar, ListingLegend, SectionHead, TierBadge, TierEmpty, filterCounts } from "../../../components/marketplace"; | |
| 14 | + | import { type ExtensionListing, extensionListings, isConnectedSystem, passes, readFilters, tiersShown } from "../../../lib/marketplace"; | |
| 11 | 15 | import { useMarketplace } from "./layout"; | |
| 12 | 16 | ||
| 17 | + | /** Each tier's section heading on the Extensions tab. */ | |
| 18 | + | const HEADINGS: Record<ListingTier, string> = { | |
| 19 | + | official: "From g1t", | |
| 20 | + | verified: "From verified publishers", | |
| 21 | + | community: "From the community", | |
| 22 | + | internal: "Built in this workspace", | |
| 23 | + | }; | |
| 24 | + | ||
| 13 | 25 | export default function MarketplaceExtensions() { | |
| 14 | 26 | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 27 | + | const [params] = useSearchParams(); | |
| 28 | + | const filters = readFilters(params); | |
| 29 | + | // A search or an availability filter can empty a tier; the tier filter alone only picks one. | |
| 30 | + | const filtered = filters.availability !== "all"; | |
| 15 | 31 | const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username); | |
| 16 | − | const published = listings.filter((l) => l.manifest.status === "available"); | |
| 17 | − | const coming = listings.filter((l) => l.manifest.status !== "available" && !isConnectedSystem(l.manifest)); | |
| 18 | − | const systems = listings.filter((l) => l.manifest.status !== "available" && isConnectedSystem(l.manifest)); | |
| 32 | + | const shown = listings.filter((listing) => passes(listing, filters)); | |
| 33 | + | const grid = (items: ExtensionListing[]) => ( | |
| 34 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 35 | + | {items.map((listing) => ( | |
| 36 | + | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 37 | + | ))} | |
| 38 | + | </div> | |
| 39 | + | ); | |
| 19 | 40 | return ( | |
| 20 | − | <div className="space-y-12"> | |
| 21 | − | {published.length > 0 && ( | |
| 22 | − | <section aria-labelledby="published"> | |
| 23 | − | <SectionHead id="published" title="Ready to install" aside={`${published.length}`} /> | |
| 24 | − | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 25 | − | {published.map((listing) => ( | |
| 26 | − | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 27 | − | ))} | |
| 28 | − | </div> | |
| 29 | − | </section> | |
| 30 | − | )} | |
| 31 | − | <section aria-labelledby="soon"> | |
| 32 | − | <SectionHead id="soon" title={published.length > 0 ? "Soon" : "Soon from g1t"} aside={`${coming.length}`}> | |
| 33 | − | Extensions add pages, data, cards and agent roles for what your team does besides code. These are g1t's own, listed before their first release; each can be installed once it is published. | |
| 34 | − | </SectionHead> | |
| 35 | − | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 36 | − | {coming.map((listing) => ( | |
| 37 | − | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 38 | − | ))} | |
| 39 | − | </div> | |
| 40 | − | </section> | |
| 41 | − | {systems.length > 0 && ( | |
| 42 | − | <section aria-labelledby="systems"> | |
| 43 | − | <SectionHead id="systems" title="Connected systems" aside={`${systems.length}`}> | |
| 44 | − | Extensions that bridge a system your team already runs, so agents work across it and g1t together. The install screen names where its data goes. | |
| 45 | − | </SectionHead> | |
| 46 | − | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 47 | − | {systems.map((listing) => ( | |
| 48 | − | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 49 | − | ))} | |
| 50 | − | </div> | |
| 51 | − | </section> | |
| 52 | − | )} | |
| 41 | + | <div className="space-y-10"> | |
| 42 | + | <ListingFilterBar kind="extension" filters={filters} counts={filterCounts(listings)} /> | |
| 43 | + | {tiersShown(filters).map((tier) => { | |
| 44 | + | const mine = shown.filter((listing) => listing.tier === tier); | |
| 45 | + | const apps = mine.filter((listing) => !isConnectedSystem(listing.manifest)); | |
| 46 | + | const systems = mine.filter((listing) => isConnectedSystem(listing.manifest)); | |
| 47 | + | return ( | |
| 48 | + | <section key={tier} aria-labelledby={`tier-${tier}`}> | |
| 49 | + | <SectionHead | |
| 50 | + | id={`tier-${tier}`} | |
| 51 | + | title={ | |
| 52 | + | <> | |
| 53 | + | <TierBadge tier={tier} /> | |
| 54 | + | {HEADINGS[tier]} | |
| 55 | + | </> | |
| 56 | + | } | |
| 57 | + | aside={`${mine.length}`} | |
| 58 | + | > | |
| 59 | + | {tier === "official" && mine.length > 0 | |
| 60 | + | ? "g1t's own, listed before their first release so you can see what each adds and may do. Each can be installed once it is published." | |
| 61 | + | : null} | |
| 62 | + | </SectionHead> | |
| 63 | + | {mine.length === 0 ? ( | |
| 64 | + | <TierEmpty tier={tier} kind="extension" filtered={filtered} /> | |
| 65 | + | ) : ( | |
| 66 | + | <div className="space-y-6"> | |
| 67 | + | {apps.length > 0 && grid(apps)} | |
| 68 | + | {systems.length > 0 && ( | |
| 69 | + | <div> | |
| 70 | + | <h3 className="text-sm font-semibold">Connected systems</h3> | |
| 71 | + | <p className="mt-0.5 mb-3 max-w-2xl text-sm text-muted"> | |
| 72 | + | Extensions that bridge a system your team already runs, so agents work across it and g1t together. Each page names where its data goes. | |
| 73 | + | </p> | |
| 74 | + | {grid(systems)} | |
| 75 | + | </div> | |
| 76 | + | )} | |
| 77 | + | </div> | |
| 78 | + | )} | |
| 79 | + | </section> | |
| 80 | + | ); | |
| 81 | + | })} | |
| 53 | 82 | <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> | |
| 54 | 83 | <h2 id="how" className="text-base font-semibold tracking-tight"> | |
| 55 | 84 | How extensions are shared | |
| ⋯ | |||
| 61 | 90 | <li>Its pages load in a sandboxed frame from g1t's user-content domain, and reach g1t only through what it was allowed.</li> | |
| 62 | 91 | <li>Every call it makes is in the audit log. Owners can cap what it spends, and switch it off at once.</li> | |
| 63 | 92 | </ul> | |
| 64 | − | <ul className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 65 | − | {LISTING_TIERS.map((tier) => ( | |
| 66 | − | <li key={tier} className="rounded-xl border border-line bg-bg p-4"> | |
| 67 | − | <TierBadge tier={tier} /> | |
| 68 | − | <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p> | |
| 69 | − | </li> | |
| 70 | − | ))} | |
| 71 | − | </ul> | |
| 72 | 93 | </section> | |
| 94 | + | <ListingLegend kind="extension" /> | |
| 73 | 95 | </div> | |
| 74 | 96 | ); | |
| 75 | 97 | } | |
| 1 | + | /** | |
| 2 | + | * One integration's page: who builds it, whether this workspace can | |
| 3 | + | * connect it (or has), and exactly what it lets agents and people do | |
| 4 | + | * today and what is still Soon, from the connector catalog | |
| 5 | + | * (@g1t/contracts/connectors) for each way it is connected: for the | |
| 6 | + | * whole workspace, and for each person. | |
| 7 | + | */ | |
| 8 | + | import { ArrowLeft, Clock, ShieldCheck, Users } from "lucide-react"; | |
| 9 | + | import { Link, data } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { connectorById, connectorView } from "@g1t/contracts/connectors"; | |
| 12 | + | ||
| 13 | + | import type { Route } from "./+types/integration"; | |
| 14 | + | import { ConnectorMark } from "../../../components/connectors"; | |
| 15 | + | import { AvailabilityBadge, IntegrationAction, ListingFacts } from "../../../components/marketplace"; | |
| 16 | + | import { cn } from "../../../lib/cn"; | |
| 17 | + | import { type IntegrationUse, TIERS, categoryTitle, comingListings, integrationListings, integrationUses, marketplacePath, personalListings } from "../../../lib/marketplace"; | |
| 18 | + | import { loadConnected } from "../../../lib/marketplace.server"; | |
| 19 | + | import { page } from "../../../lib/meta"; | |
| 20 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 21 | + | import { useMarketplace } from "./layout"; | |
| 22 | + | ||
| 23 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 24 | + | const connector = connectorById(params.integration); | |
| 25 | + | return page(args, { title: `${connector?.name ?? "Integration"} · Marketplace · ${params.owner} · g1t` }); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 29 | + | const viewer = requireUser(context, request); | |
| 30 | + | const slug = params.owner.toLowerCase(); | |
| 31 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 32 | + | if (!connectorById(params.integration)) throw data(null, { status: 404 }); | |
| 33 | + | return await loadConnected(slug, viewer); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | export default function MarketplaceIntegration({ params, loaderData }: Route.ComponentProps) { | |
| 37 | + | const { slug, owner, username, requests } = useMarketplace(); | |
| 38 | + | const connector = connectorById(params.integration)!; | |
| 39 | + | const workspaceView = connectorView(connector, "workspace"); | |
| 40 | + | const personalView = connectorView(connector, "personal"); | |
| 41 | + | // The listing this page acts on: the workspace's, or each person's when it is only theirs. | |
| 42 | + | const listing = workspaceView | |
| 43 | + | ? workspaceView.status === "available" | |
| 44 | + | ? integrationListings([workspaceView], loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable)[0]! | |
| 45 | + | : comingListings([workspaceView], [], slug)[0]! | |
| 46 | + | : personalView!.status === "available" && personalView!.href | |
| 47 | + | ? personalListings([personalView!], slug, loaderData.unavailable)[0]! | |
| 48 | + | : comingListings([], [personalView!], slug)[0]!; | |
| 49 | + | const uses = integrationUses(connector); | |
| 50 | + | const tier = TIERS[listing.tier]; | |
| 51 | + | return ( | |
| 52 | + | <div> | |
| 53 | + | <Link to={marketplacePath(slug, "integrations")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 54 | + | <ArrowLeft size={14} /> | |
| 55 | + | All integrations | |
| 56 | + | </Link> | |
| 57 | + | <header className="mt-5 flex flex-wrap items-center gap-4"> | |
| 58 | + | <span className={cn("shrink-0", (listing.availability === "soon" || listing.availability === "unavailable") && "opacity-60 grayscale")}> | |
| 59 | + | <ConnectorMark view={listing.view} size={64} /> | |
| 60 | + | </span> | |
| 61 | + | <div className="min-w-0 grow basis-60"> | |
| 62 | + | <h2 className="text-xl font-semibold tracking-tight">{connector.name}</h2> | |
| 63 | + | <ListingFacts tier={listing.tier} publisher={listing.publisher} category={categoryTitle(connector.category)}> | |
| 64 | + | <AvailabilityBadge | |
| 65 | + | availability={listing.availability} | |
| 66 | + | kind="integration" | |
| 67 | + | why={listing.why} | |
| 68 | + | hint={listing.availability === "available" && !owner && listing.scope === "workspace" ? "An owner connects it for everyone. Ask one with Request." : undefined} | |
| 69 | + | /> | |
| 70 | + | </ListingFacts> | |
| 71 | + | </div> | |
| 72 | + | <IntegrationAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" /> | |
| 73 | + | </header> | |
| 74 | + | {listing.availability === "unavailable" && listing.why && ( | |
| 75 | + | <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted"> | |
| 76 | + | <span className="font-medium text-fg-soft">Not available here.</span> {listing.why} | |
| 77 | + | </p> | |
| 78 | + | )} | |
| 79 | + | {listing.availability === "soon" && ( | |
| 80 | + | <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted"> | |
| 81 | + | Planned, not built yet: nobody can connect it. This page shows what it will do. | |
| 82 | + | </p> | |
| 83 | + | )} | |
| 84 | + | <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{listing.view.description}</p> | |
| 85 | + | ||
| 86 | + | <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 87 | + | <section aria-labelledby="does" className="min-w-0"> | |
| 88 | + | <h3 id="does" className="text-sm font-semibold"> | |
| 89 | + | What it lets agents and people do | |
| 90 | + | </h3> | |
| 91 | + | <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 92 | + | <div> | |
| 93 | + | <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-success"> | |
| 94 | + | <ShieldCheck size={13} /> | |
| 95 | + | {listing.availability === "unavailable" ? "Once it can be connected here" : "Today"} | |
| 96 | + | </h4> | |
| 97 | + | {uses.today.length === 0 ? ( | |
| 98 | + | <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing yet. It can't be connected until it is built.</p> | |
| 99 | + | ) : ( | |
| 100 | + | <div className="space-y-3"> | |
| 101 | + | {uses.today.map((use) => ( | |
| 102 | + | <Use key={use.scope} use={use} said={listing.view.description} /> | |
| 103 | + | ))} | |
| 104 | + | </div> | |
| 105 | + | )} | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-muted"> | |
| 109 | + | <Clock size={13} /> | |
| 110 | + | Soon | |
| 111 | + | </h4> | |
| 112 | + | {uses.soon.length === 0 ? ( | |
| 113 | + | <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing more is planned for it yet.</p> | |
| 114 | + | ) : ( | |
| 115 | + | <div className="space-y-3"> | |
| 116 | + | {uses.soon.map((use) => ( | |
| 117 | + | <Use key={use.scope} use={use} said={listing.view.description} soon /> | |
| 118 | + | ))} | |
| 119 | + | </div> | |
| 120 | + | )} | |
| 121 | + | </div> | |
| 122 | + | </div> | |
| 123 | + | </section> | |
| 124 | + | ||
| 125 | + | <aside className="text-sm"> | |
| 126 | + | <dl className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 127 | + | <div> | |
| 128 | + | <dt className="text-xs text-faint">Publisher</dt> | |
| 129 | + | <dd className="mt-0.5"> | |
| 130 | + | {listing.publisher} · {tier.label} | |
| 131 | + | <span className="block text-xs text-muted">{tier.about}</span> | |
| 132 | + | </dd> | |
| 133 | + | </div> | |
| 134 | + | <div> | |
| 135 | + | <dt className="flex items-center gap-1.5 text-xs text-faint"> | |
| 136 | + | <Users size={12} /> | |
| 137 | + | Who connects it | |
| 138 | + | </dt> | |
| 139 | + | <dd className="mt-0.5"> | |
| 140 | + | {connector.scopes.includes("workspace") && connector.scopes.includes("personal") | |
| 141 | + | ? "An owner, once for the whole workspace; and each person, for their own account." | |
| 142 | + | : connector.scopes.includes("workspace") | |
| 143 | + | ? "An owner, once for the whole workspace." | |
| 144 | + | : "Each person, for their own account. Agents use it only when that person asks."} | |
| 145 | + | </dd> | |
| 146 | + | </div> | |
| 147 | + | {listing.connected && ( | |
| 148 | + | <div> | |
| 149 | + | <dt className="text-xs text-faint">Connected</dt> | |
| 150 | + | <dd className="mt-0.5 break-words">{listing.connected.detail}</dd> | |
| 151 | + | {listing.connected.problem && <dd className="mt-1 text-warn">{listing.connected.problem}</dd>} | |
| 152 | + | </div> | |
| 153 | + | )} | |
| 154 | + | </dl> | |
| 155 | + | </aside> | |
| 156 | + | </div> | |
| 157 | + | </div> | |
| 158 | + | ); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /** One way it is connected: who connects it, what it does that way, and its capabilities. */ | |
| 162 | + | function Use({ use, said, soon = false }: { use: IntegrationUse; said: string; soon?: boolean }) { | |
| 163 | + | return ( | |
| 164 | + | <div className={cn("rounded-xl border p-4", soon ? "border-dashed border-line-strong/70" : "border-line bg-surface")}> | |
| 165 | + | <p className="text-xs text-faint">{use.who}</p> | |
| 166 | + | {use.description !== said && <p className={cn("mt-1 text-sm", soon ? "text-muted" : "text-fg-soft")}>{use.description}</p>} | |
| 167 | + | {use.capabilities.length > 0 && ( | |
| 168 | + | <ul className="mt-2.5 flex flex-wrap gap-1.5" aria-label={soon ? "What it will do" : "What it does"}> | |
| 169 | + | {use.capabilities.map((capability) => ( | |
| 170 | + | <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", soon ? "border border-dashed border-line text-faint" : "bg-raised text-fg-soft")}> | |
| 171 | + | {capability} | |
| 172 | + | </li> | |
| 173 | + | ))} | |
| 174 | + | </ul> | |
| 175 | + | )} | |
| 176 | + | </div> | |
| 177 | + | ); | |
| 178 | + | } |
| 1 | 1 | /** | |
| 2 | − | * Integrations in the Marketplace: every connector a workspace can connect | |
| 3 | − | * today, connected ones first; those each person connects for themselves; | |
| 4 | − | * then the ones the catalog lists as coming. Each workspace one is set up | |
| 5 | − | * on its own page under Workspace → Integrations; owners connect, anyone | |
| 6 | − | * else asks. | |
| 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. | |
| 7 | 10 | */ | |
| 8 | 11 | import { Search } from "lucide-react"; | |
| 9 | 12 | import { useState } from "react"; | |
| 10 | − | import { Link } from "react-router"; | |
| 13 | + | import { Link, useSearchParams } from "react-router"; | |
| 11 | 14 | ||
| 12 | − | import { type ConnectorView, connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors"; | |
| 15 | + | import { CONNECTORS, connectorView, connectorsFor } from "@g1t/contracts/connectors"; | |
| 13 | 16 | ||
| 14 | 17 | import type { Route } from "./+types/integrations"; | |
| 15 | − | import { ConnectorMark } from "../../../components/connectors"; | |
| 16 | − | import { ComingBadge, IntegrationCard, SectionHead } from "../../../components/marketplace"; | |
| 17 | − | import { comingIntegrations, integrationListings, integrationMatches } from "../../../lib/marketplace"; | |
| 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"; | |
| 18 | 20 | import { loadConnected } from "../../../lib/marketplace.server"; | |
| 19 | 21 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 20 | 22 | import { useMarketplace } from "./layout"; | |
| ⋯ | |||
| 23 | 25 | const viewer = requireUser(context, request); | |
| 24 | 26 | const slug = params.owner.toLowerCase(); | |
| 25 | 27 | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); | |
| 26 | − | return { connected: await loadConnected(slug, viewer) }; | |
| 28 | + | return await loadConnected(slug, viewer); | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 31 | + | const HEADINGS = { official: "From g1t", verified: "From verified publishers", community: "From the community", internal: "Built in this workspace" } as const; | |
| 32 | + | ||
| 29 | 33 | export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) { | |
| 30 | 34 | const { slug, owner, username, requests } = useMarketplace(); | |
| 31 | 35 | const [query, setQuery] = useState(""); | |
| 36 | + | const [params] = useSearchParams(); | |
| 37 | + | const filters = readFilters(params); | |
| 32 | 38 | const views = connectorsFor("workspace"); | |
| 33 | − | const all = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug); | |
| 34 | − | const shown = all.filter((listing) => integrationMatches(listing, query)); | |
| 35 | − | const connected = shown.filter((listing) => listing.connected); | |
| 36 | − | const available = shown.filter((listing) => !listing.connected); | |
| 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]; | |
| 37 | 49 | const wanted = query.trim().toLowerCase(); | |
| 38 | − | const personal = CONNECTORS.filter((c) => !c.scopes.includes("workspace")).map((c) => connectorView(c, "personal")).filter((view) => view != null); | |
| 39 | − | // What each person connects for themselves, today. | |
| 40 | − | const yours = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view): view is ConnectorView & { href: string } => view != null && view.status === "available" && view.href != null && (!wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted)))); | |
| 41 | − | const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted))); | |
| 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 | + | ); | |
| 42 | 61 | return ( | |
| 43 | 62 | <div className="space-y-10"> | |
| 44 | − | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 45 | − | <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"> | |
| 46 | − | <Search size={15} className="shrink-0 text-faint" /> | |
| 47 | − | <input | |
| 48 | − | value={query} | |
| 49 | − | onChange={(event) => setQuery(event.target.value)} | |
| 50 | − | placeholder="Find an integration" | |
| 51 | − | aria-label="Find an integration" | |
| 52 | − | autoComplete="off" | |
| 53 | − | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 54 | − | /> | |
| 55 | − | </label> | |
| 56 | − | <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg"> | |
| 57 | − | Workspace integrations settings | |
| 58 | − | </Link> | |
| 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)} /> | |
| 59 | 81 | </div> | |
| 60 | − | ||
| 61 | − | {connected.length > 0 && ( | |
| 62 | − | <section aria-labelledby="connected"> | |
| 63 | − | <SectionHead id="connected" title="Connected" aside={`${connected.length}`} /> | |
| 64 | − | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 65 | − | {connected.map((listing) => ( | |
| 66 | − | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 67 | − | ))} | |
| 68 | − | </div> | |
| 69 | − | </section> | |
| 70 | − | )} | |
| 71 | 82 | ||
| 72 | − | <section aria-labelledby="available"> | |
| 73 | − | <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`}> | |
| 74 | − | Connected once for the whole workspace by an owner. Each says what it lets agents do. | |
| 75 | − | </SectionHead> | |
| 76 | − | {available.length === 0 ? ( | |
| 77 | − | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">{wanted ? "No integration you can connect matches." : "Everything available is connected."}</p> | |
| 78 | − | ) : ( | |
| 79 | − | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 80 | − | {available.map((listing) => ( | |
| 81 | − | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 82 | − | ))} | |
| 83 | − | </div> | |
| 84 | − | )} | |
| 85 | − | </section> | |
| 86 | − | ||
| 87 | − | {yours.length > 0 && ( | |
| 88 | − | <section aria-labelledby="yours"> | |
| 89 | − | <SectionHead id="yours" title="Connected by each person" aside={`${yours.length}`}> | |
| 90 | − | Each person connects these for themselves, and agents use them only when that person asks. | |
| 91 | − | </SectionHead> | |
| 92 | − | <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 93 | − | {yours.map((view) => ( | |
| 94 | − | <li key={view.id} className="flex flex-col rounded-xl border border-line bg-surface p-4"> | |
| 95 | − | <div className="flex grow items-start gap-3"> | |
| 96 | − | <ConnectorMark view={view} size={40} /> | |
| 97 | − | <div className="min-w-0 grow"> | |
| 98 | − | <h3 className="truncate text-sm font-semibold">{view.name}</h3> | |
| 99 | − | <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p> | |
| 100 | − | </div> | |
| 101 | − | </div> | |
| 102 | − | <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 103 | − | <span className="truncate text-xs text-faint">Each person's own</span> | |
| 104 | − | <Link to={view.href} className="inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg"> | |
| 105 | − | Connect yours | |
| 106 | − | </Link> | |
| 107 | − | </div> | |
| 108 | − | </li> | |
| 109 | − | ))} | |
| 110 | − | </ul> | |
| 111 | − | </section> | |
| 112 | − | )} | |
| 113 | − | ||
| 114 | − | {coming.length > 0 && ( | |
| 115 | − | <section aria-labelledby="coming"> | |
| 116 | − | <SectionHead id="coming" title="Coming" aside={<ComingBadge />}> | |
| 117 | − | Planned, not built. Nobody can connect these yet. | |
| 118 | − | </SectionHead> | |
| 119 | − | <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 120 | − | {coming.map((view) => ( | |
| 121 | − | <li key={view.id} className="flex items-center gap-3 rounded-lg border border-dashed border-line px-3 py-2.5"> | |
| 122 | − | <ConnectorMark view={view} size={28} /> | |
| 123 | − | <span className="min-w-0"> | |
| 124 | − | <span className="block truncate text-sm">{view.name}</span> | |
| 125 | − | <span className="block truncate text-xs text-faint">{view.description}</span> | |
| 126 | − | </span> | |
| 127 | − | </li> | |
| 128 | − | ))} | |
| 129 | − | </ul> | |
| 130 | − | </section> | |
| 131 | − | )} | |
| 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" /> | |
| 132 | 151 | </div> | |
| 133 | 152 | ); | |
| 134 | 153 | } | |
| 329 | 329 | ||
| 330 | 330 | export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"]; | |
| 331 | 331 | ||
| 332 | + | /** | |
| 333 | + | * Who stands behind every integration: the connector catalog | |
| 334 | + | * (./connectors.ts) is g1t's own, so each connector is Official. | |
| 335 | + | */ | |
| 336 | + | export const CONNECTOR_PUBLISHER: { name: string; tier: ListingTier } = { name: "g1t", tier: "official" }; | |
| 337 | + | ||
| 332 | 338 | /** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */ | |
| 333 | 339 | export type ListingRef = { kind: ListingKind; id: string }; | |
| 334 | 340 |