Skip to content
456 linesCodeBlameRaw
1/**
2 * Customize's pieces (routes/workspace/customize/): the card every listing
3 * shares, the badges that say whether something can be added or is
4 * connected, the Available-to control that says which agents something
5 * reaches, the Request button members ask owners with, a request as owners
6 * and askers see it, filter menus, and section headings.
7 */
8import { Check, ChevronDown, Clock, Info, Plus, Send, Users, X } from "lucide-react";
9import { type ReactNode, useEffect, useId, useState } from "react";
10import { Link, useFetcher } from "react-router";
11
12import type { ConnectorState, InstallRequest, PluginAvailability } from "@g1t/contracts";
13import { customizePath } from "@g1t/contracts/customize";
14
15import type { useDialogFetcher } from "./agents/dialogs";
16import { ConnectorMark } from "./connectors";
17import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "./ui";
18import { Badge, type BadgeTone } from "./ui/badge";
19import { Button, buttonVariants } from "./ui/button";
20import { Card } from "./ui/card";
21import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
22import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
23import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
24import { Hint } from "./ui/hint";
25import { Input } from "./ui/input";
26import { Textarea } from "./ui/textarea";
27import { cn } from "../lib/cn";
28import { CONNECTOR_STATES, type Reach, type ReachOption, type ReachTargets, availabilityWord, kindWord, reachOptions, reachWords } from "../lib/customize";
29
30/** A listing's mark: its letters on a steady colour, from its id and name. */
31export function ListingMark({ id, name, size = 40 }: { id: string; name: string; size?: number }) {
32 return <ConnectorMark view={{ id, name, provider: null }} size={size} />;
33}
34
35/**
36 * The card every listing shares: its mark, its name (a link over the whole
37 * card when it has a page), one line about it, what sits under it
38 * (`children`), and a last line with where it is from (`meta`) and what
39 * can be done with it (`action`). `muted` draws one nobody can add yet.
40 */
41export function ListingCard({
42 mark,
43 title,
44 to,
45 description,
46 badge,
47 meta,
48 action,
49 muted = false,
50 children,
51}: {
52 mark: ReactNode;
53 title: ReactNode;
54 to?: string;
55 description: ReactNode;
56 badge?: ReactNode;
57 meta?: ReactNode;
58 action?: ReactNode;
59 muted?: boolean;
60 children?: ReactNode;
61}) {
62 return (
63 <Card asChild className={cn("relative flex flex-col gap-3 p-4 transition-colors", muted ? "border-dashed bg-transparent" : "hover:border-line-strong")}>
64 <article>
65 <div className="flex items-start gap-3">
66 <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>{mark}</span>
67 <div className="min-w-0 grow">
68 <div className="flex items-start justify-between gap-2">
69 <h3 className={cn("min-w-0 truncate text-sm font-semibold", muted && "text-fg-soft")}>
70 {to ? (
71 <Link to={to} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
72 {title}
73 </Link>
74 ) : (
75 title
76 )}
77 </h3>
78 {badge && <span className="relative z-10 flex shrink-0 items-center gap-1.5">{badge}</span>}
79 </div>
80 <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{description}</p>
81 </div>
82 </div>
83 {children && <div className="relative z-10">{children}</div>}
84 <div className="relative z-10 mt-auto flex min-h-8 items-center justify-between gap-3">
85 <p className="min-w-0 truncate text-xs text-muted">{meta}</p>
86 {action && <span className="flex shrink-0 items-center gap-2">{action}</span>}
87 </div>
88 </article>
89 </Card>
90 );
91}
92
93/** Small chips under a card: what a plugin brings, a connector's tools. */
94export function Chips({ items, muted = false, label }: { items: readonly string[]; muted?: boolean; label?: string }) {
95 if (items.length === 0) return null;
96 return (
97 <ul className="flex flex-wrap gap-1.5" aria-label={label}>
98 {items.map((item) => (
99 <li key={item} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}>
100 {item}
101 </li>
102 ))}
103 </ul>
104 );
105}
106
107/** A section's heading, with a count or a link at its end, and a line under it. */
108export function SectionHead({ id, title, aside, children }: { id: string; title: ReactNode; aside?: ReactNode; children?: ReactNode }) {
109 return (
110 <div className="mb-3">
111 <div className="flex items-baseline justify-between gap-3">
112 <h2 id={id} className="flex items-center gap-2 text-base font-semibold tracking-tight">
113 {title}
114 </h2>
115 {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
116 </div>
117 {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
118 </div>
119 );
120}
121
122/** The grid listings sit in. */
123export function ListingGrid({ children }: { children: ReactNode }) {
124 return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{children}</div>;
125}
126
127/** Discover's banner: what is open to add here, in a wash of the accent. */
128export function FeaturedBanner({ title, children, action }: { title: ReactNode; children: ReactNode; action?: ReactNode }) {
129 return (
130 <Card asChild className="relative overflow-hidden p-5 sm:p-6">
131 <section aria-label="Featured">
132 <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]" />
133 <div className="relative flex flex-wrap items-end justify-between gap-4">
134 <div className="min-w-0 max-w-2xl">
135 <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
136 <p className="mt-1 text-sm text-muted">{children}</p>
137 </div>
138 {action}
139 </div>
140 </section>
141 </Card>
142 );
143}
144
145/** A plugin's or a skill's availability as a badge, with why it isn't available here on hover. */
146export function AvailabilityBadge({ availability }: { availability: PluginAvailability }) {
147 const { label, tone } = availabilityWord(availability);
148 const hint =
149 availability.kind === "unavailable"
150 ? availability.why
151 : availability.kind === "soon"
152 ? "g1t's own, announced and not released yet. Nobody can install it until it is."
153 : availability.kind === "installed" && !availability.enabled
154 ? "Installed and switched off: its skills, agents and connectors are kept but reach no agent until an owner switches it on."
155 : null;
156 const badge = <Badge tone={tone}>{label}</Badge>;
157 if (!hint) return badge;
158 return (
159 <Hint label={hint}>
160 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
161 {badge}
162 </span>
163 </Hint>
164 );
165}
166
167/** A connector's state as a badge: Connected, Needs sign-in, Needs a key, or Problem with what is wrong on hover. */
168export function StateBadge({ state, problem }: { state: ConnectorState; problem?: string | null }) {
169 const { label, tone } = CONNECTOR_STATES[state];
170 const badge = <Badge tone={tone}>{label}</Badge>;
171 if (!problem) return badge;
172 return (
173 <Hint label={problem}>
174 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
175 {badge}
176 </span>
177 </Hint>
178 );
179}
180
181/** Something to know before connecting: an information mark with the words on hover. */
182export function Caveat({ text }: { text: string | null | undefined }) {
183 if (!text) return null;
184 return (
185 <Hint label={text}>
186 <span tabIndex={0} aria-label="Something to know" className="inline-flex rounded-full text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
187 <Info size={14} />
188 </span>
189 </Hint>
190 );
191}
192
193/**
194 * A menu that picks one of `options` (a category, a marketplace, a sort),
195 * as a quiet button naming the choice; `all` is the word for none.
196 */
197export function FilterMenu({ label, all, value, options, onChange }: { label: string; all: string; value: string | null; options: readonly { value: string; label: string }[]; onChange: (value: string | null) => void }) {
198 const chosen = options.find((option) => option.value === value);
199 return (
200 <DropdownMenu>
201 <DropdownMenuTrigger asChild>
202 <Button type="button" variant={chosen ? "soft" : "outline"} size="sm" aria-label={`${label}: ${chosen?.label ?? all}`}>
203 <span className="text-muted">{label}</span>
204 <span className="max-w-40 truncate">{chosen?.label ?? all}</span>
205 <ChevronDown size={13} className="text-faint" />
206 </Button>
207 </DropdownMenuTrigger>
208 <DropdownMenuContent align="start">
209 <DropdownMenuLabel>{label}</DropdownMenuLabel>
210 <DropdownMenuRadioGroup value={value ?? ""} onValueChange={(next) => onChange(next || null)}>
211 <DropdownMenuRadioItem value="">{all}</DropdownMenuRadioItem>
212 {options.map((option) => (
213 <DropdownMenuRadioItem key={option.value} value={option.value}>
214 {option.label}
215 </DropdownMenuRadioItem>
216 ))}
217 </DropdownMenuRadioGroup>
218 </DropdownMenuContent>
219 </DropdownMenu>
220 );
221}
222
223/**
224 * Where something reaches, and the control that changes it: a menu of
225 * every agent, each team and each agent, checked where it already reaches.
226 * Checking gives it there; unchecking takes it back. Without `targets`
227 * (someone who may not change it) the line alone shows.
228 */
229export function AvailableTo({
230 reach,
231 targets,
232 busy = false,
233 onChange,
234 size = "xs",
235}: {
236 reach: readonly Reach[];
237 targets: ReachTargets | null;
238 busy?: boolean;
239 /** Give it where `option` says (`checked`), or take it back from there (`option.reach`). */
240 onChange?: (option: ReachOption, checked: boolean) => void;
241 size?: "xs" | "sm";
242}) {
243 const words = reachWords(reach);
244 const options = targets ? reachOptions(reach, targets) : [];
245 if (!targets || !onChange || options.length === 0) {
246 return (
247 <span className={cn("inline-flex items-center gap-1.5 text-muted", size === "xs" ? "text-xs" : "text-[0.8125rem]")}>
248 <Users size={size === "xs" ? 12 : 13} className="shrink-0 text-faint" aria-hidden />
249 <span className="truncate">{reach.length === 0 ? "Not available to any agent yet" : `Available to ${words}`}</span>
250 </span>
251 );
252 }
253 return (
254 <DropdownMenu>
255 <DropdownMenuTrigger asChild>
256 <Button type="button" variant="outline" size={size} disabled={busy} className="max-w-full" aria-label={`Available to ${words}; change where it reaches`}>
257 <Users size={size === "xs" ? 12 : 13} className="text-faint" />
258 <span className="text-muted">Available to</span>
259 <span className="truncate">{words}</span>
260 <ChevronDown size={12} className="text-faint" />
261 </Button>
262 </DropdownMenuTrigger>
263 <DropdownMenuContent align="start" className="min-w-56">
264 <DropdownMenuLabel>Available to</DropdownMenuLabel>
265 {options.map((option) => (
266 <DropdownMenuCheckboxItem key={option.value} checked={option.checked} onCheckedChange={(checked) => onChange(option, checked === true)}>
267 {option.label}
268 </DropdownMenuCheckboxItem>
269 ))}
270 </DropdownMenuContent>
271 </DropdownMenu>
272 );
273}
274
275/** "3 waiting": open requests for a listing, for owners, leading to the requests page. */
276export function Waiting({ slug, count }: { slug: string; count: number }) {
277 if (count === 0) return null;
278 return (
279 <Link to={customizePath("requests", slug)} className="text-xs text-warn hover:underline">
280 {count} {count === 1 ? "request" : "requests"}
281 </Link>
282 );
283}
284
285/**
286 * Asking the workspace's owners to add something: a dialog that says what
287 * is asked for, with an optional note, sent to the requests page. Once
288 * sent, it says so.
289 */
290export function RequestButton({ slug, listing, name, requested, className, size = "sm" }: { slug: string; listing: string; name: string; requested: boolean; className?: string; size?: "xs" | "sm" }) {
291 const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
292 const [open, setOpen] = useState(false);
293 const sent = fetcher.state === "idle" && fetcher.data?.error === null;
294 useEffect(() => {
295 if (sent) setOpen(false);
296 }, [sent]);
297 if (requested || sent) {
298 return (
299 <Hint label="The workspace's owners have your request. You'll hear when they answer.">
300 <span tabIndex={0} className={cn(buttonVariants({ variant: "outline", size }), "text-muted hover:border-line hover:bg-transparent hover:text-muted", className)}>
301 <Clock size={13} />
302 Requested
303 </span>
304 </Hint>
305 );
306 }
307 return (
308 <Dialog open={open} onOpenChange={setOpen}>
309 <DialogTrigger asChild>
310 <Button variant="outline" size={size} className={className}>
311 <Send size={13} />
312 Request
313 </Button>
314 </DialogTrigger>
315 <DialogContent>
316 <fetcher.Form method="post" action={customizePath("requests", slug)} className="grid gap-4">
317 <DialogHeader>
318 <DialogTitle>Ask to add {name}</DialogTitle>
319 <DialogDescription>Only the workspace's owners add skills, connectors and plugins. Each of them is notified, and you hear back when one answers.</DialogDescription>
320 </DialogHeader>
321 <input type="hidden" name="intent" value="request" />
322 <input type="hidden" name="listing" value={listing} />
323 <label className="grid gap-1.5 text-sm">
324 <span className="font-medium">
325 Why you want it <span className="font-normal text-faint">(optional)</span>
326 </span>
327 <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
328 </label>
329 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
330 <DialogFooter>
331 <SubmitButton fetcher={fetcher} pending="Sending…">
332 <Send size={14} />
333 Send request
334 </SubmitButton>
335 </DialogFooter>
336 </fetcher.Form>
337 </DialogContent>
338 </Dialog>
339 );
340}
341
342/** A request's status, in a word. */
343export function RequestStatus({ request }: { request: InstallRequest }) {
344 const tone: BadgeTone = request.status === "open" ? "warn" : request.status === "done" ? "success" : "neutral";
345 return <Badge tone={tone}>{request.status === "open" ? "Waiting" : request.status === "done" ? "Added" : "Turned down"}</Badge>;
346}
347
348/**
349 * One request as a row: what was asked for, by whom, why and when; for an
350 * owner, while it waits, the way to add it, mark it added, or turn it down.
351 */
352export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
353 const fetcher = useFetcher<{ error: string | null }>();
354 return (
355 <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
356 <div className="min-w-0 grow">
357 <p className="flex flex-wrap items-center gap-2 text-sm">
358 <span className="font-medium">{request.name}</span>
359 <span className="text-xs text-faint">{kindWord(request.kind)}</span>
360 <RequestStatus request={request} />
361 </p>
362 <p className="mt-0.5 text-xs text-muted">
363 {owner ? `@${request.requested_by} asked ` : "You asked "}
364 <TimeAgo at={request.requested_at} />
365 {request.resolved_by && request.status !== "open" && (
366 <>
367 {" · "}
368 {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
369 </>
370 )}
371 </p>
372 {request.note && <p className="mt-1.5 max-w-xl border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
373 {fetcher.data?.error && (
374 <div className="mt-2">
375 <ErrorText>{fetcher.data.error}</ErrorText>
376 </div>
377 )}
378 </div>
379 {owner && request.status === "open" && (
380 <fetcher.Form method="post" action={customizePath("requests", slug)} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
381 <input type="hidden" name="intent" value="resolve" />
382 <input type="hidden" name="id" value={request.id} />
383 {addTo && (
384 <ButtonLink to={addTo} size="sm">
385 <Plus size={14} />
386 {request.kind === "connector" || request.kind === "integration" ? "Connect" : request.kind === "skill" ? "Add" : "Install"}
387 </ButtonLink>
388 )}
389 <SubmitButton fetcher={fetcher} name="status" value="done" variant="outline" size="sm" pending="Saving…">
390 <Check size={14} />
391 Mark added
392 </SubmitButton>
393 <SubmitButton fetcher={fetcher} name="status" value="declined" variant="outline" size="sm" pending="Saving…">
394 <X size={14} />
395 Turn down
396 </SubmitButton>
397 </fetcher.Form>
398 )}
399 </li>
400 );
401}
402
403/**
404 * Owners add a marketplace by address: `owner/repo` on GitHub or a
405 * repository here, a git address, or a link to its marketplace.json. Posted
406 * to the Marketplaces page, from wherever the dialog opens.
407 */
408export function AddMarketplaceDialog({ slug, dialog }: { slug: string; dialog: ReturnType<typeof useDialogFetcher> }) {
409 const { fetcher, open, setOpen, error, busy } = dialog;
410 const id = useId();
411 return (
412 <Dialog open={open} onOpenChange={setOpen}>
413 <DialogContent>
414 <DialogHeader>
415 <DialogTitle>Add a marketplace</DialogTitle>
416 <DialogDescription>
417 A repository with <code className="font-mono text-xs">.claude-plugin/marketplace.json</code>: on GitHub as owner/repo, a repository here as workspace/name, a git address, or a link to the file itself. Its plugins
418 show in Discover, in the formats Claude Code reads.
419 </DialogDescription>
420 </DialogHeader>
421 <fetcher.Form method="post" action={customizePath("marketplaces", slug)} className="grid gap-5">
422 <input type="hidden" name="intent" value="add" />
423 <Field>
424 <FieldLabel htmlFor={`${id}-address`}>Address</FieldLabel>
425 <Input id={`${id}-address`} name="address" placeholder="anthropics/claude-plugins-official" required autoComplete="off" spellCheck={false} className="font-mono" />
426 </Field>
427 <Field>
428 <FieldLabel htmlFor={`${id}-name`}>
429 Name <span className="font-normal text-faint">(optional)</span>
430 </FieldLabel>
431 <Input id={`${id}-name`} name="name" placeholder="As its manifest names it" autoComplete="off" spellCheck={false} />
432 <FieldDescription>Plugins are named plugin@marketplace, so a name you choose is the one your people see.</FieldDescription>
433 </Field>
434 <FieldError>{error}</FieldError>
435 <DialogFooter>
436 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
437 Cancel
438 </Button>
439 <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "add" }} busy={busy} pending="Reading it…">
440 Add marketplace
441 </SubmitButton>
442 </DialogFooter>
443 </fetcher.Form>
444 </DialogContent>
445 </Dialog>
446 );
447}
448
449/** What a page says it can't show: the agents service didn't answer. */
450export function NoAnswer({ what }: { what: string }) {
451 return (
452 <Card asChild tone="plain" className="border-dashed px-4 py-8 text-center text-sm text-muted">
453 <p>{what} can't be shown right now. The agents service didn't answer; reload in a minute.</p>
454 </Card>
455 );
456}