| 1 | /** |
| 2 | * The Marketplace: what adds functionality to a workspace, which anyone in |
| 3 | * it may browse. Extensions add pages, data and cards; integrations connect |
| 4 | * what the team already uses so agents can work with it. Owners add them; |
| 5 | * everyone else asks an owner, and the agents service keeps the request |
| 6 | * (lib/marketplace.ts). Agents aren't here: they start from templates in |
| 7 | * Agents mode. It is not in the dock: the Apps page and the launcher lead |
| 8 | * here, as does ⌘K. |
| 9 | * |
| 10 | * This layout holds the heading, the tabs and the viewer's requests, which |
| 11 | * every tab reads to say what is waiting. |
| 12 | */ |
| 13 | import { Blocks, Inbox, Plug, Sparkles } from "lucide-react"; |
| 14 | import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router"; |
| 15 | |
| 16 | import type { ExtensionInstall, InstallRequests } from "@g1t/contracts"; |
| 17 | |
| 18 | import type { Route } from "./+types/layout"; |
| 19 | import { TabStrip } from "../../../components/ui/tab-strip"; |
| 20 | import { marketplacePath, openRequests } from "../../../lib/marketplace"; |
| 21 | import { page } from "../../../lib/meta"; |
| 22 | import { workspaceAgents } from "../../../lib/services.server"; |
| 23 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 24 | |
| 25 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 26 | return page(args, { title: `Marketplace · ${params.owner} · g1t` }); |
| 27 | } |
| 28 | |
| 29 | export type MarketplaceData = { |
| 30 | slug: string; |
| 31 | name: string; |
| 32 | owner: boolean; |
| 33 | username: string; |
| 34 | /** The viewer's requests (an owner's: everyone's); null when the agents service did not answer. */ |
| 35 | requests: InstallRequests | null; |
| 36 | /** The extensions installed in the workspace; empty when the agents service did not answer. */ |
| 37 | installs: ExtensionInstall[]; |
| 38 | }; |
| 39 | |
| 40 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<MarketplaceData> { |
| 41 | const viewer = requireUser(context, request); |
| 42 | const slug = params.owner.toLowerCase(); |
| 43 | const role = roleIn(viewer, slug); |
| 44 | if (!role) throw data(null, { status: 404 }); |
| 45 | const membership = viewer.workspaces?.find((m) => m.slug === slug); |
| 46 | const [requests, installs] = await Promise.all([ |
| 47 | workspaceAgents |
| 48 | .installRequests(slug, viewer) |
| 49 | .then((result) => (result.ok ? result.value : null)) |
| 50 | .catch(() => null), |
| 51 | workspaceAgents |
| 52 | .extensionInstalls(slug, viewer) |
| 53 | .then((result) => (result.ok ? result.value : [])) |
| 54 | .catch(() => []), |
| 55 | ]); |
| 56 | return { slug, name: membership?.name?.trim() || slug, owner: role === "owner", username: viewer.username, requests, installs }; |
| 57 | } |
| 58 | |
| 59 | /** The Marketplace's own data, from any of its pages. */ |
| 60 | export function useMarketplace(): MarketplaceData { |
| 61 | return useRouteLoaderData("routes/workspace/marketplace/layout") as MarketplaceData; |
| 62 | } |
| 63 | |
| 64 | export default function MarketplaceLayout({ loaderData }: Route.ComponentProps) { |
| 65 | const { slug, name, owner, requests } = loaderData; |
| 66 | const waiting = requests ? openRequests(requests.requests).length : 0; |
| 67 | const { pathname } = useLocation(); |
| 68 | const tabs = [ |
| 69 | { to: marketplacePath(slug), label: "Discover", icon: <Sparkles size={14} />, end: true }, |
| 70 | { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> }, |
| 71 | { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> }, |
| 72 | { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting }, |
| 73 | ]; |
| 74 | return ( |
| 75 | <main className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10"> |
| 76 | <div className="flex flex-wrap items-end justify-between gap-4"> |
| 77 | <div> |
| 78 | <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1> |
| 79 | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| 80 | Add what {name} does besides code, and connect the tools you already use so agents can work with your data.{" "} |
| 81 | {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."} |
| 82 | </p> |
| 83 | </div> |
| 84 | <Link to={`/${slug}/-/apps`} className="text-sm text-muted hover:text-fg"> |
| 85 | Your apps |
| 86 | </Link> |
| 87 | </div> |
| 88 | <TabStrip label="Marketplace" className="mt-6 gap-1 border-b border-line"> |
| 89 | {tabs.map((tab) => ( |
| 90 | <NavLink |
| 91 | key={tab.to} |
| 92 | to={tab.to} |
| 93 | end={tab.end} |
| 94 | prefetch="intent" |
| 95 | data-active={tab.end ? pathname === tab.to : pathname.startsWith(tab.to)} |
| 96 | className={({ isActive }) => |
| 97 | `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${ |
| 98 | isActive ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg" |
| 99 | }` |
| 100 | } |
| 101 | > |
| 102 | {tab.icon} |
| 103 | {tab.label} |
| 104 | {tab.count != null && tab.count > 0 && <span className="rounded-full bg-warn/15 px-1.5 py-px text-xs text-warn tabular-nums">{tab.count}</span>} |
| 105 | </NavLink> |
| 106 | ))} |
| 107 | </TabStrip> |
| 108 | <div className="mt-8"> |
| 109 | <Outlet /> |
| 110 | </div> |
| 111 | </main> |
| 112 | ); |
| 113 | } |