Zum Inhalt springen
UI Kit
Accent

Integrationsverzeichnis

Ein ganzes Verzeichnis verbundener Werkzeuge mit neutral gezeichneten Monogrammen statt Logos: ein durchsuchbarer Katalog, eine Integrationsseite, ein Register von A bis Z und die Ansicht in der App mit Schaltern.

4 VariantsEine von 2 Components in Integrations

Jede Verbindung, leicht zu finden

Ein Integrationsverzeichnis beantwortet, ob dein Produkt mit den Werkzeugen arbeitet, die Menschen schon nutzen. Dieses zeichnet für jedes Werkzeug ein Monogramm statt fremder Logos, das Verzeichnis wirkt also einheitlich und braucht keine Erlaubnis, eine Marke zu zeigen.

„Katalog mit Suche“ setzt die Suche nach oben und Kategorien mit mitzählenden Zahlen in eine Seitenleiste, die am Telefon eine Chipreihe wird, dazu ein Kartenraster und ein leerer Zustand, der fragt, was fehlt. Die „Integrationsseite“ erzählt eine Integration ganz, beide Zeichen verbunden durch eine Linie, die beim Verbinden aufleuchtet, was sie kann, die Schritte zur Einrichtung, die Daten, die sie sieht, und Verwandtes in einer klebenden Seitenspalte. Das „Register von A bis Z“ zeigt empfohlene Werkzeuge oben und darunter jeden Eintrag nach Buchstaben, die Buchstabenleiste klebt beim Scrollen und markiert den sichtbaren Buchstaben. „Verbindungen verwalten“ ist die Ansicht in der App mit Reitern für alle und verbundene, einer Suche und echten Schaltern mit kurzem Ladezustand.

Lass die leere Suche fragen, was fehlt, die Antworten sagen dir, welche Integration als Nächstes kommt. Nenn auf jeder Integrationsseite die Daten, die sie sieht, das beantwortet die Sicherheitsfrage, bevor sie gestellt wird. Nutze im Verzeichnis und in der App dieselben Namen.

Variants

Katalog mit SucheNeu in 1.2

Oben die Suche, Kategorien mit mitzählenden Zahlen in einer Seitenleiste, die auf dem Telefon eine Chipreihe wird, ein Kartenraster, das aufsteigt, und ein leerer Zustand, der fragt, was fehlt.

IntegrationsseiteNeu in 1.2

Eine Integration ganz erzählt: beide Zeichen verbunden durch eine Linie, die beim Verbinden aufleuchtet, was sie kann, die Schritte zur Einrichtung, die Daten, die sie sieht, und Verwandtes in einer klebenden Seitenspalte.

Register von A bis ZNeu in 1.2

Empfohlene Werkzeuge oben, darunter jeder Eintrag nach Buchstaben. Die Buchstabenleiste klebt beim Scrollen, markiert den sichtbaren Buchstaben und springt auf Klick, Buchstaben ohne Einträge bleiben still.

Verbindungen verwaltenNeu in 1.2

Die Ansicht in der App: Reiter für alle und verbundene mit Zahlen, eine Suche, Einträge nach Kategorie und echte Schalter, die mit kurzem Ladezustand verbinden.

Details

Was Integrationsverzeichnis mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Integrations
Bereich
Content
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/integration-directory.tsx
Bringt mit
utilskitmotionbuttoniconsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 30 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle IntegrationDirectoryProps aus components/ui/integration-directory.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    IntegrationDirectoryVariant

    catalogdetaila-zmanage

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsPflicht
    IntegrationItem[]
  • categoriesoptional
    string[]

    Order of the categories, derived from the items otherwise

  • featuredoptional
    string[]

    a-z: names shown large on top

  • detailoptional
    IntegrationDetail

    detail: the integration is items[0], this is its page

  • requestoptional
    { label: string; href: string }

    Where to ask for a missing integration

Eingebaute Texte

Diese Texte schreibt Integrationsverzeichnis selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 30 Texte anzeigen
searchEnglisch Search integrationsDeutsch Integrationen durchsuchen
searchPhEnglisch Search by name or what it doesDeutsch Nach Name oder Aufgabe suchen
allEnglisch AllDeutsch Alle
categoriesEnglisch CategoriesDeutsch Kategorien
resultsEnglisch {n} integrationsDeutsch {n} Integrationen
result1Englisch 1 integrationDeutsch 1 Integration
emptyEnglisch Nothing matches “{q}” yet.Deutsch Zu „{q}“ passt noch nichts.
emptyHintEnglisch Tell us what you use, we build the most requested ones first.Deutsch Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst.
clearEnglisch Clear searchDeutsch Suche leeren
popularEnglisch PopularDeutsch Beliebt
newEnglisch NewDeutsch Neu
betaEnglisch BetaDeutsch Beta
connectEnglisch ConnectDeutsch Verbinden
connectingEnglisch ConnectingDeutsch Wird verbunden
connectedEnglisch ConnectedDeutsch Verbunden
disconnectEnglisch DisconnectDeutsch Trennen
configureEnglisch SettingsDeutsch Einstellungen
syncedEnglisch Synced just nowDeutsch Gerade abgeglichen
whatEnglisch What it doesDeutsch Was es kann
setupEnglisch Set up in {n} stepsDeutsch In {n} Schritten eingerichtet
permissionsEnglisch What it can seeDeutsch Worauf es zugreift
aboutEnglisch AboutDeutsch Steckbrief
relatedEnglisch Works well withDeutsch Passt gut zu
jumpEnglisch Jump to letterDeutsch Zum Buchstaben springen
featuredEnglisch FeaturedDeutsch Empfohlen
tabAllEnglisch AllDeutsch Alle
tabOnEnglisch ConnectedDeutsch Verbunden
availableEnglisch AvailableDeutsch Verfügbar
noneOnEnglisch Nothing connected yet. Switch on what you use below.Deutsch Noch nichts verbunden. Schalte unten ein, was du nutzt.
openEnglisch View {name}Deutsch {name} ansehen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Integrationsverzeichnis reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-busy, aria-checked, aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen switch, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 769 Zeilen
components/ui/integration-directory.tsx
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { useScrollFade } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * A full directory of the tools a product connects to. Services never show a
 * third-party logo: every entry gets a neutral monogram, a tile with initials
 * and a small drawn shape picked from its name, so a list stays calm and
 * nobody's trademark sits in the kit. Pass `mark` to use your own artwork.
 *
 *   catalog  search, categories in a side rail (chips on phones), card grid, empty state
 *   detail   one integration: the connection drawn, what it does, setup, permissions
 *   a-z      an alphabet bar that jumps, featured on top, grouped columns
 *   manage   the in-app view: connected and available, switches with real states
 */

export type IntegrationDirectoryVariant = 'catalog' | 'detail' | 'a-z' | 'manage';

export interface IntegrationItem {
  name: string;
  category: string;
  description: string;
  badge?: 'popular' | 'new' | 'beta';
  /** manage: starts connected */
  connected?: boolean;
  href?: string;
  /** Your own mark instead of the drawn monogram */
  mark?: React.ReactNode;
}

export interface IntegrationDetail {
  /** Your product, shown on the left of the connection */
  product: string;
  features: { icon?: IconName; title: string; text?: string }[];
  steps: string[];
  permissions: string[];
  facts: { label: string; value: string }[];
  docs?: { label: string; href: string };
}

export interface IntegrationDirectoryProps {
  variant?: IntegrationDirectoryVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  items: IntegrationItem[];
  /** Order of the categories, derived from the items otherwise */
  categories?: string[];
  /** a-z: names shown large on top */
  featured?: string[];
  /** detail: the integration is items[0], this is its page */
  detail?: IntegrationDetail;
  /** Where to ask for a missing integration */
  request?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search integrations',
    searchPh: 'Search by name or what it does',
    all: 'All',
    categories: 'Categories',
    results: '{n} integrations',
    result1: '1 integration',
    empty: 'Nothing matches “{q}” yet.',
    emptyHint: 'Tell us what you use, we build the most requested ones first.',
    clear: 'Clear search',
    popular: 'Popular',
    new: 'New',
    beta: 'Beta',
    connect: 'Connect',
    connecting: 'Connecting',
    connected: 'Connected',
    disconnect: 'Disconnect',
    configure: 'Settings',
    synced: 'Synced just now',
    what: 'What it does',
    setup: 'Set up in {n} steps',
    permissions: 'What it can see',
    about: 'About',
    related: 'Works well with',
    jump: 'Jump to letter',
    featured: 'Featured',
    tabAll: 'All',
    tabOn: 'Connected',
    available: 'Available',
    noneOn: 'Nothing connected yet. Switch on what you use below.',
    open: 'View {name}',
  },
  de: {
    search: 'Integrationen durchsuchen',
    searchPh: 'Nach Name oder Aufgabe suchen',
    all: 'Alle',
    categories: 'Kategorien',
    results: '{n} Integrationen',
    result1: '1 Integration',
    empty: 'Zu „{q}“ passt noch nichts.',
    emptyHint: 'Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst.',
    clear: 'Suche leeren',
    popular: 'Beliebt',
    new: 'Neu',
    beta: 'Beta',
    connect: 'Verbinden',
    connecting: 'Wird verbunden',
    connected: 'Verbunden',
    disconnect: 'Trennen',
    configure: 'Einstellungen',
    synced: 'Gerade abgeglichen',
    what: 'Was es kann',
    setup: 'In {n} Schritten eingerichtet',
    permissions: 'Worauf es zugreift',
    about: 'Steckbrief',
    related: 'Passt gut zu',
    jump: 'Zum Buchstaben springen',
    featured: 'Empfohlen',
    tabAll: 'Alle',
    tabOn: 'Verbunden',
    available: 'Verfügbar',
    noneOn: 'Noch nichts verbunden. Schalte unten ein, was du nutzt.',
    open: '{name} ansehen',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = IntegrationDirectoryProps & { t: Texts };

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const hash = (s: string) => [...s].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
const initials = (name: string) => {
  const w = name.split(/[\s-]+/).filter(Boolean);
  return (w.length > 1 ? w[0][0] + w[1][0] : name.slice(0, 2)).toUpperCase();
};

/* Six small shapes, drawn on a 20 px grid, one per service picked from its name */
const SHAPES = [
  <circle key="c" cx="10" cy="10" r="4.5" />,
  <rect key="s" x="5.5" y="5.5" width="9" height="9" rx="2" />,
  <path key="t" d="M10 4.5 15.5 14.5h-11Z" />,
  <path key="d" d="m10 4 6 6-6 6-6-6Z" />,
  <path key="h" d="M6 5h8M6 10h8M6 15h8" />,
  <path key="a" d="M4.5 14a5.5 5.5 0 0 1 11 0" />,
];

/** A neutral mark for a service: a tinted tile, its initials and one drawn shape */
export function IntegrationMark({ name, mark, size = 'md', className }: { name: string; mark?: React.ReactNode; size?: 'sm' | 'md' | 'lg'; className?: string }) {
  const h = hash(name);
  const box = size === 'lg' ? 'size-16 rounded-2xl text-[19px]' : size === 'sm' ? 'size-9 rounded-lg text-[12px]' : 'size-11 rounded-xl text-[14px]';
  if (mark) return <span className={cn('grid shrink-0 place-items-center overflow-hidden border border-border bg-card', box, className)}>{mark}</span>;
  const mixes = [14, 22, 30, 18, 26];
  return (
    <span
      aria-hidden
      className={cn('relative grid shrink-0 place-items-center overflow-hidden border border-[color-mix(in_oklab,var(--foreground)_10%,transparent)] font-semibold tracking-[-0.02em] text-foreground shadow-[var(--highlight)]', box, className)}
      style={{ background: `color-mix(in oklab, var(--primary) ${mixes[h % mixes.length]}%, var(--card))` }}
    >
      <svg viewBox="0 0 20 20" className="absolute bottom-0.5 right-0.5 size-[34%] text-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))] opacity-50" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
        {SHAPES[(h >> 3) % SHAPES.length]}
      </svg>
      <span className="relative">{initials(name)}</span>
    </span>
  );
}

function Badge({ kind, t }: { kind?: IntegrationItem['badge']; t: Texts }) {
  if (!kind) return null;
  return (
    <span
      className={cn(
        'inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-medium',
        kind === 'new' && 'bg-primary text-primary-foreground',
        kind === 'popular' && 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]',
        kind === 'beta' && 'border border-border-strong text-muted-foreground',
      )}
    >
      {t[kind]}
    </span>
  );
}

function Head({ eyebrow, title, description, center, className }: Pick<IntegrationDirectoryProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
  return (
    <div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
          {description}
        </p>
      )}
    </div>
  );
}

function SearchBox({ value, onChange, t, className }: { value: string; onChange: (v: string) => void; t: Texts; className?: string }) {
  const id = useId();
  return (
    <div className={cn('relative', className)}>
      <label htmlFor={id} className="sr-only">
        {t.search}
      </label>
      <Icon name="search" size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
      <input
        id={id}
        type="search"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={t.searchPh}
        autoComplete="off"
        className="h-12 w-full rounded-xl border border-border bg-card pl-11 pr-11 text-base text-foreground shadow-[var(--highlight),var(--shadow-sm)] placeholder:text-subtle-foreground transition-[border-color,box-shadow] hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px] [&::-webkit-search-cancel-button]:hidden"
      />
      {value && (
        <button type="button" onClick={() => onChange('')} aria-label={t.clear} className="absolute right-1 top-1/2 grid size-10 -translate-y-1/2 cursor-pointer place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
          <Icon name="x" size={16} />
        </button>
      )}
    </div>
  );
}

const match = (it: IntegrationItem, q: string) => {
  const s = q.trim().toLowerCase();
  return !s || `${it.name} ${it.description} ${it.category}`.toLowerCase().includes(s);
};

function useCategories(p: IntegrationDirectoryProps) {
  return useMemo(() => p.categories ?? [...new Set(p.items.map((i) => i.category))], [p.categories, p.items]);
}

function Empty({ q, t, request, onClear }: { q: string; t: Texts; request?: IntegrationDirectoryProps['request']; onClear: () => void }) {
  return (
    <div className="kit-rise flex flex-col items-center rounded-2xl border border-dashed border-border-strong px-6 py-14 text-center">
      <span className="grid size-12 place-items-center rounded-full bg-surface text-muted-foreground">
        <Icon name="plug" size={22} />
      </span>
      <p className="mt-4 text-[16px] font-semibold text-foreground">{fill(t.empty, { q })}</p>
      <p className="mt-1.5 max-w-[24rem] text-pretty text-[14px] text-muted-foreground">{t.emptyHint}</p>
      <div className="mt-6 flex flex-col gap-2 sm:flex-row">
        {request && (
          <Button href={request.href} icon="arrow-right">
            {request.label}
          </Button>
        )}
        <Button variant="outline" onClick={onClear}>
          {t.clear}
        </Button>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* catalog                                                             */
/* ------------------------------------------------------------------ */

function Card({ it, t, index }: { it: IntegrationItem; t: Texts; index: number }) {
  return (
    <a
      href={it.href ?? '#'}
      aria-label={fill(t.open, { name: it.name })}
      className="group/card kit-rise relative grid h-full cursor-pointer grid-cols-[auto_minmax(0,1fr)] content-start gap-x-3.5 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:flex sm:flex-col sm:p-5 transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:hover:translate-y-0"
      style={delay(Math.min(index, 8) * 40)}
    >
      <span className="row-span-3 flex items-start justify-between gap-3 sm:row-span-1">
        <IntegrationMark name={it.name} mark={it.mark} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/card:scale-[1.06] motion-reduce:transition-none" />
        <span className="max-sm:hidden"><Badge kind={it.badge} t={t} /></span>
      </span>
      <span className="flex items-center gap-2 text-[15.5px] font-semibold text-foreground sm:mt-4">
        {it.name}
        <span className="sm:hidden"><Badge kind={it.badge} t={t} /></span>
      </span>
      <span className="mt-1 line-clamp-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{it.description}</span>
      <span className="mt-auto flex items-center justify-between gap-2 pt-4 text-[12.5px] text-subtle-foreground max-sm:hidden">
        {it.category}
        <Icon name="arrow-up-right" size={15} className="text-muted-foreground opacity-0 transition-[opacity,transform] duration-300 group-hover/card:translate-x-0.5 group-hover/card:opacity-100 group-focus-visible/card:opacity-100" />
      </span>
    </a>
  );
}

function Catalog(p: ViewProps) {
  const { t } = p;
  const cats = useCategories(p);
  const [cat, setCat] = useState<string | null>(null);
  const [q, setQ] = useState('');
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const list = p.items.filter((it) => (!cat || it.category === cat) && match(it, q));
  const count = (c: string | null) => p.items.filter((it) => (!c || it.category === c) && match(it, q)).length;
  const choices: (string | null)[] = [null, ...cats];
  const name = (c: string | null) => c ?? t.all;
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:items-end">
        <Head {...p} />
        <SearchBox value={q} onChange={setQ} t={t} className="kit-rise" />
      </div>
      <div className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-8 lg:mt-14 lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-10">
        {/* categories: a rail on desktop, a scroll row of chips on phones and tablets */}
        <nav aria-label={t.categories} className="min-w-0">
          <p className="mb-3 text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground max-lg:sr-only">{t.categories}</p>
          <div ref={rowRef} className={cn('kit-no-scrollbar relative -mx-6 flex gap-2 overflow-x-auto px-6 lg:mx-0 lg:flex-col lg:gap-0.5 lg:overflow-visible lg:px-0', fade, 'lg:[mask-image:none]')}>
            {choices.map((c) => {
              const on = cat === c;
              return (
                <button
                  key={c ?? 'all'}
                  type="button"
                  aria-pressed={on}
                  onClick={() => setCat(c)}
                  className={cn(
                    'flex min-h-11 shrink-0 cursor-pointer items-center justify-between gap-3 rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary lg:min-h-10 lg:rounded-lg lg:border-transparent lg:px-3',
                    on ? 'border-foreground bg-foreground text-background lg:bg-accent lg:text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground lg:hover:bg-surface',
                  )}
                >
                  <span className="whitespace-nowrap">{name(c)}</span>
                  <span className={cn('text-[12px] tabular-nums', on ? 'opacity-80' : 'text-subtle-foreground')}>{count(c)}</span>
                </button>
              );
            })}
          </div>
        </nav>
        <div className="min-w-0">
          <p aria-live="polite" className="mb-4 text-[13px] text-muted-foreground">
            {list.length === 1 ? t.result1 : fill(t.results, { n: list.length })}
          </p>
          {list.length ? (
            <ul className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 xl:grid-cols-3">
              {list.map((it, i) => (
                <li key={it.name} className="min-w-0">
                  <Card it={it} t={t} index={i} />
                </li>
              ))}
            </ul>
          ) : (
            <Empty q={q} t={t} request={p.request} onClear={() => { setQ(''); setCat(null); }} />
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* detail                                                              */
/* ------------------------------------------------------------------ */

function Detail(p: ViewProps) {
  const { t } = p;
  const it = p.items[0];
  const d = p.detail;
  const [state, setState] = useState<'off' | 'busy' | 'on'>(it?.connected ? 'on' : 'off');
  const [ref, inView] = useInView<HTMLDivElement>();
  const toggle = () => {
    if (state === 'busy') return;
    if (state === 'on') return setState('off');
    setState('busy');
    window.setTimeout(() => setState('on'), prefersReducedMotion() ? 0 : 1200);
  };
  if (!it || !d) return null;
  const related = p.items.slice(1, 4);
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,21rem)] lg:gap-16">
        <div className="min-w-0">
          {/* the connection: our mark, a line with travelling dots, their mark */}
          <div ref={ref} className="kit-rise flex items-center gap-3">
            <IntegrationMark name={d.product} size="lg" />
            <span aria-hidden className="relative h-px w-16 bg-border [--id-w:4rem] sm:w-24 sm:[--id-w:6rem]">
              <span className={cn('absolute inset-0 origin-left bg-primary transition-transform duration-700 ease-[var(--ease-kit)]', state === 'on' ? 'scale-x-100' : 'scale-x-0')} />
              {state !== 'off' && inView && (
                <>
                  <span className="absolute -top-[3px] left-0 size-[7px] animate-[id-travel_1.6s_linear_infinite] rounded-full bg-primary shadow-[0_0_8px_var(--glow)] motion-reduce:hidden" />
                  <span className="absolute -top-[3px] left-0 size-[7px] animate-[id-travel_1.6s_linear_.8s_infinite] rounded-full bg-primary opacity-60 motion-reduce:hidden" />
                </>
              )}
              <span className={cn('absolute left-1/2 top-1/2 grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border bg-background transition-colors duration-300', state === 'on' ? 'border-primary text-primary' : 'border-border text-muted-foreground')}>
                <Icon name={state === 'on' ? 'check' : 'plug'} size={12} strokeWidth={2} />
              </span>
            </span>
            <IntegrationMark name={it.name} mark={it.mark} size="lg" />
            <style>{'@keyframes id-travel{from{transform:translateX(0);opacity:0}15%{opacity:1}85%{opacity:1}to{transform:translateX(var(--id-w,5.5rem));opacity:0}}'}</style>
          </div>
          <div className="mt-8 flex flex-wrap items-center gap-3">
            <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
            <Badge kind={it.badge} t={t} />
          </div>
          {p.description && <p className="kit-rise mt-4 max-w-[38rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>{p.description}</p>}
          <div className="kit-rise mt-8 flex flex-col gap-3 sm:flex-row sm:items-center" style={delay(140)}>
            <Button size="lg" onClick={toggle} variant={state === 'on' ? 'outline' : 'primary'} iconLeft={state === 'on' ? 'check' : state === 'off' ? 'plug' : undefined} aria-live="polite" className="max-sm:w-full">
              {state === 'busy' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
              {state === 'busy' ? t.connecting : state === 'on' ? t.connected : t.connect}
            </Button>
            {state === 'on' && (
              <button type="button" onClick={toggle} className="kit-rise inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg px-3 text-[14px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
                {t.disconnect}
              </button>
            )}
            {d.docs && (
              <a href={d.docs.href} className="inline-flex min-h-11 cursor-pointer items-center justify-center gap-1.5 text-[14px] font-medium text-foreground underline-offset-4 hover:underline sm:ml-2">
                {d.docs.label}
                <Icon name="external" size={15} />
              </a>
            )}
          </div>

          <h3 className="mt-14 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.what}</h3>
          <ul className="mt-5 grid gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2">
            {d.features.map((f, i) => (
              <li key={i} className="bg-card p-5">
                <div className="kit-rise" style={delay(i * 70)}>
                  <Icon name={f.icon ?? 'check'} size={19} className="text-primary" />
                  <p className="mt-3 text-[15px] font-semibold text-foreground">{f.title}</p>
                  {f.text && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{f.text}</p>}
                </div>
              </li>
            ))}
          </ul>

          <h3 className="mt-14 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{fill(t.setup, { n: d.steps.length })}</h3>
          <ol className="relative mt-5 grid gap-5">
            <span aria-hidden className="absolute bottom-3 left-[15px] top-3 w-px bg-border" />
            {d.steps.map((s, i) => (
              <li key={i} className="relative flex items-start gap-4">
                <span className="relative grid size-[31px] shrink-0 place-items-center rounded-full border border-border bg-card text-[13px] font-semibold text-foreground">{i + 1}</span>
                <p className="pt-1 text-pretty text-[15px] leading-relaxed text-foreground">{s}</p>
              </li>
            ))}
          </ol>
        </div>

        <aside className="grid content-start gap-4 lg:sticky lg:top-24">
          <div className="kit-rise rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(160)}>
            <h3 className="text-[14px] font-semibold text-foreground">{t.about}</h3>
            <dl className="mt-4 grid gap-3 text-[14px]">
              {d.facts.map((f) => (
                <div key={f.label} className="flex justify-between gap-4 border-b border-border pb-3 last:border-b-0 last:pb-0">
                  <dt className="text-muted-foreground">{f.label}</dt>
                  <dd className="text-right font-medium text-foreground">{f.value}</dd>
                </div>
              ))}
            </dl>
          </div>
          <div className="kit-rise rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(220)}>
            <h3 className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
              <Icon name="shield" size={16} className="text-primary" />
              {t.permissions}
            </h3>
            <ul className="mt-4 grid gap-2.5 text-[14px] text-muted-foreground">
              {d.permissions.map((x, i) => (
                <li key={i} className="flex gap-2.5">
                  <Icon name="eye" size={15} className="mt-0.5 shrink-0 text-subtle-foreground" />
                  <span className="text-pretty">{x}</span>
                </li>
              ))}
            </ul>
          </div>
          {related.length > 0 && (
            <div className="kit-rise rounded-2xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(280)}>
              <h3 className="px-4 pb-1 pt-4 text-[14px] font-semibold text-foreground">{t.related}</h3>
              <ul>
                {related.map((r) => (
                  <li key={r.name}>
                    <a href={r.href ?? '#'} className="group/rel flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-4 py-2 transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
                      <IntegrationMark name={r.name} mark={r.mark} size="sm" />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-[14px] font-medium text-foreground">{r.name}</span>
                        <span className="block truncate text-[12.5px] text-muted-foreground">{r.category}</span>
                      </span>
                      <Icon name="chevron-right" size={16} className="text-subtle-foreground transition-transform group-hover/rel:translate-x-0.5" />
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </aside>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* a-z                                                                 */
/* ------------------------------------------------------------------ */

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');

function AZ(p: ViewProps) {
  const { t } = p;
  const base = useId().replace(/:/g, '');
  const locale = useLocale();
  const [q, setQ] = useState('');
  const sorted = useMemo(() => [...p.items].sort((a, b) => a.name.localeCompare(b.name, locale)), [p.items, locale]);
  const list = sorted.filter((it) => match(it, q));
  const groups = ALPHABET.map((L) => ({ L, items: list.filter((it) => it.name[0]?.toUpperCase() === L) }));
  const rest = list.filter((it) => !ALPHABET.includes(it.name[0]?.toUpperCase() ?? ''));
  if (rest.length) groups.push({ L: '#', items: rest });
  const featured = (p.featured ?? []).map((n) => p.items.find((i) => i.name === n)).filter(Boolean) as IntegrationItem[];
  const [active, setActive] = useState<string | null>(null);
  // a click on a letter holds that letter while the page scrolls there, the observer takes over afterwards
  const hold = useRef(0);
  const [barRef, fade] = useScrollFade<HTMLDivElement>();
  useEffect(() => {
    const els = groups.filter((g) => g.items.length).map((g) => document.getElementById(`${base}-${g.L}`)).filter(Boolean) as HTMLElement[];
    if (!els.length) return;
    // the letter in view is the last section whose top has passed a quarter of the window, none above the list
    let frame = 0;
    const read = () => {
      frame = 0;
      if (Date.now() < hold.current) return;
      const line = window.innerHeight * 0.25;
      const passed = els.filter((el) => el.getBoundingClientRect().top <= line);
      const last = passed[passed.length - 1];
      const end = last && last.getBoundingClientRect().bottom < 0;
      setActive(last && !end ? (last.id.split('-').pop() ?? null) : null);
    };
    const onScroll = () => { if (!frame) frame = window.requestAnimationFrame(read); };
    read();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      if (frame) window.cancelAnimationFrame(frame);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [q, base]);
  return (
    <Section className={p.className}>
      <Head {...p} />
      {featured.length > 0 && !q && (
        <div className="mt-12">
          <p className="text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.featured}</p>
          <ul className="mt-4 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-4">
            {featured.map((it, i) => (
              <li key={it.name} className="min-w-0">
                <a
                  href={it.href ?? '#'}
                  className="group/f kit-rise relative flex h-full cursor-pointer items-center gap-4 overflow-hidden rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] hover:shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary"
                  style={delay(i * 70)}
                >
                  <IntegrationMark name={it.name} mark={it.mark} />
                  <span className="min-w-0">
                    <span className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
                      <span className="truncate">{it.name}</span>
                      <Badge kind={it.badge} t={t} />
                    </span>
                    <span className="block truncate text-[13px] text-muted-foreground">{it.category}</span>
                  </span>
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}
      {/* the bar sticks while the list scrolls under it */}
      <SearchBox value={q} onChange={setQ} t={t} className="mt-12 lg:hidden" />
      <div className="sticky top-0 z-10 -mx-6 mt-4 border-b border-border bg-[color-mix(in_oklab,var(--background)_88%,transparent)] px-6 py-2 backdrop-blur lg:-mx-8 lg:mt-12 lg:px-8 lg:py-3">
        <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-6">
          <SearchBox value={q} onChange={setQ} t={t} className="max-lg:hidden lg:w-80 lg:shrink-0" />
          <nav aria-label={t.jump} className="min-w-0 flex-1">
            <div ref={barRef} className={cn('kit-no-scrollbar flex gap-0.5 overflow-x-auto', fade)}>
              {groups.map(({ L, items }) =>
                items.length ? (
                  <a
                    key={L}
                    href={`#${base}-${L}`}
                    aria-current={active === L ? 'location' : undefined}
                    onClick={() => {
                      hold.current = Date.now() + 1200;
                      setActive(L);
                    }}
                    className={cn(
                      'grid size-11 shrink-0 cursor-pointer place-items-center rounded-lg text-[14px] font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary lg:size-8 lg:text-[13px]',
                      active === L ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent',
                    )}
                  >
                    {L}
                  </a>
                ) : (
                  <span key={L} aria-hidden className="grid size-11 shrink-0 place-items-center text-[14px] text-[color-mix(in_oklab,var(--foreground)_25%,transparent)] lg:size-8 lg:text-[13px]">
                    {L}
                  </span>
                ),
              )}
            </div>
          </nav>
        </div>
      </div>
      {list.length === 0 ? (
        <div className="mt-8">
          <Empty q={q} t={t} request={p.request} onClear={() => setQ('')} />
        </div>
      ) : (
        <div className="mt-2">
          {groups
            .filter((g) => g.items.length)
            .map(({ L, items }) => (
              <section key={L} id={`${base}-${L}`} aria-labelledby={`${base}-${L}-h`} className="grid scroll-mt-40 grid-cols-[minmax(0,1fr)] gap-4 border-b border-border py-8 sm:grid-cols-[4rem_minmax(0,1fr)] lg:scroll-mt-24">
                <h3 id={`${base}-${L}-h`} className="text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:pt-2">
                  {L}
                </h3>
                <ul className="grid grid-cols-[minmax(0,1fr)] gap-1 sm:grid-cols-2 lg:grid-cols-3">
                  {items.map((it) => (
                    <li key={it.name} className="min-w-0">
                      <a href={it.href ?? '#'} className="group/i flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-2 py-2 transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
                        <IntegrationMark name={it.name} mark={it.mark} size="sm" />
                        <span className="min-w-0 flex-1">
                          <span className="flex items-center gap-2">
                            <span className="truncate text-[14.5px] font-medium text-foreground">{it.name}</span>
                            <Badge kind={it.badge} t={t} />
                          </span>
                          <span className="mt-0.5 block text-pretty text-[13px] leading-snug text-muted-foreground">{it.description}</span>
                        </span>
                      </a>
                    </li>
                  ))}
                </ul>
              </section>
            ))}
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* manage                                                              */
/* ------------------------------------------------------------------ */

function Toggle({ on, busy, onChange, label }: { on: boolean; busy: boolean; onChange: () => void; label: string }) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      aria-busy={busy || undefined}
      aria-label={label}
      onClick={onChange}
      className="relative grid h-11 w-14 shrink-0 cursor-pointer place-items-center rounded-full focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary"
    >
      <span className={cn('relative h-6 w-11 rounded-full transition-colors duration-300', on ? 'bg-primary' : 'bg-track ring-1 ring-inset ring-border-strong')}>
        <span className={cn('absolute left-0.5 top-0.5 grid size-5 place-items-center rounded-full shadow-[var(--shadow-sm)] transition-[transform,background-color] duration-300 ease-[var(--ease-kit)]', on ? 'translate-x-5 bg-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_50%,var(--card))]')}>
          {busy && <Icon name="loader" size={12} className="animate-kit-spin text-primary [--kit-duration:0.8s]" />}
        </span>
      </span>
    </button>
  );
}

function Manage(p: ViewProps) {
  const { t } = p;
  const cats = useCategories(p);
  const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(p.items.map((i) => [i.name, Boolean(i.connected)])));
  const [busy, setBusy] = useState<Record<string, boolean>>({});
  const [tab, setTab] = useState<0 | 1>(0);
  const [q, setQ] = useState('');
  const timers = useRef<number[]>([]);
  useEffect(() => () => timers.current.forEach((x) => window.clearTimeout(x)), []);
  const flip = (name: string) => {
    if (busy[name]) return;
    if (on[name]) return setOn((x) => ({ ...x, [name]: false }));
    setBusy((x) => ({ ...x, [name]: true }));
    setOn((x) => ({ ...x, [name]: true }));
    timers.current.push(window.setTimeout(() => setBusy((x) => ({ ...x, [name]: false })), prefersReducedMotion() ? 0 : 1100));
  };
  const n = Object.values(on).filter(Boolean).length;
  const list = p.items.filter((it) => match(it, q) && (tab === 0 || on[it.name]));
  const tabs = [`${t.tabAll}`, `${t.tabOn}`];
  const uid = useId();
  return (
    <Section background="surface" className={p.className}>
      <Head {...p} />
      <div className="kit-rise mt-10 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]" style={delay(140)}>
        <div className="flex flex-col gap-3 border-b border-border p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
          <div role="tablist" className="inline-flex gap-1 rounded-xl bg-surface p-1" onKeyDown={(e) => {
            if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
            e.preventDefault();
            const next = tab === 0 ? 1 : 0;
            setTab(next);
            (e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
          }}>
            {tabs.map((label, i) => (
              <button
                key={i}
                type="button"
                role="tab"
                id={`${uid}-t${i}`}
                aria-selected={tab === i}
                aria-controls={`${uid}-p`}
                tabIndex={tab === i ? 0 : -1}
                onClick={() => setTab(i as 0 | 1)}
                className={cn('inline-flex h-11 flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-4 text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-9', tab === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
              >
                {label}
                <span className={cn('rounded-full px-1.5 text-[11.5px] tabular-nums', tab === i ? 'bg-muted' : 'bg-transparent')}>{i === 0 ? p.items.length : n}</span>
              </button>
            ))}
          </div>
          <SearchBox value={q} onChange={setQ} t={t} className="sm:w-72 [&_input]:h-11 [&_input]:shadow-none" />
        </div>
        <div role="tabpanel" id={`${uid}-p`} aria-labelledby={`${uid}-t${tab}`}>
          {list.length === 0 ? (
            <p className="px-6 py-14 text-center text-[14px] text-muted-foreground">{tab === 1 && !q ? t.noneOn : fill(t.empty, { q })}</p>
          ) : (
            cats
              .map((c) => ({ c, items: list.filter((it) => it.category === c) }))
              .filter((g) => g.items.length)
              .map(({ c, items }) => (
                <div key={c}>
                  <p className="border-b border-border bg-surface px-4 py-2 text-[12px] font-medium uppercase tracking-[0.1em] text-subtle-foreground sm:px-6">{c}</p>
                  <ul>
                    {items.map((it) => {
                      const isOn = Boolean(on[it.name]);
                      const isBusy = Boolean(busy[it.name]);
                      return (
                        <li key={it.name} className="flex items-center gap-3 border-b border-border px-4 py-3.5 last:border-b-0 sm:gap-4 sm:px-6">
                          <IntegrationMark name={it.name} mark={it.mark} />
                          <div className="min-w-0 flex-1">
                            <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
                              <span className="text-[15px] font-semibold text-foreground">{it.name}</span>
                              <Badge kind={it.badge} t={t} />
                            </p>
                            <p className="mt-0.5 text-pretty text-[13.5px] text-muted-foreground">{it.description}</p>
                            <p aria-live="polite" className={cn('mt-1 flex items-center gap-1.5 text-[12.5px]', isOn ? 'text-success-foreground' : 'text-subtle-foreground')}>
                              <span className={cn('size-1.5 rounded-full', isOn ? (isBusy ? 'animate-kit-pulse bg-primary' : 'bg-success') : 'bg-[color-mix(in_oklab,var(--foreground)_25%,transparent)]')} aria-hidden />
                              {isBusy ? t.connecting : isOn ? `${t.connected} · ${t.synced}` : t.available}
                            </p>
                          </div>
                          {isOn && !isBusy && (
                            <a href={it.href ?? '#'} aria-label={`${t.configure}, ${it.name}`} className="kit-rise grid size-11 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary max-sm:hidden">
                              <Icon name="settings" size={18} />
                            </a>
                          )}
                          <Toggle on={isOn} busy={isBusy} onChange={() => flip(it.name)} label={`${it.name}, ${isOn ? t.connected : t.connect}`} />
                        </li>
                      );
                    })}
                  </ul>
                </div>
              ))
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */

const VIEWS: Record<IntegrationDirectoryVariant, (p: ViewProps) => React.ReactNode> = {
  catalog: Catalog,
  detail: Detail,
  'a-z': AZ,
  manage: Manage,
};

export function IntegrationDirectory({ variant = 'catalog', labels, ...rest }: IntegrationDirectoryProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? Catalog;
  return <View {...rest} variant={variant} t={t} />;
}

Häufige Fragen

Wie installiere ich Integrationsverzeichnis?

Mit aiw add @ai-website/integration-directory. Der Befehl legt die Datei components/ui/integration-directory.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion, button, icon und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Integrationsverzeichnis?

4 Varianten, Katalog mit Suche, Integrationsseite, Register von A bis Z und Verbindungen verwalten. Du wählst sie mit der Prop variant, zum Beispiel variant="detail".

Kann ich die Texte von Integrationsverzeichnis übersetzen?

Ja. Integrationsverzeichnis bringt 30 eigene Texte auf Deutsch und Englisch mit, etwa „Integrationen durchsuchen“, „Nach Name oder Aufgabe suchen“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Integrationsverzeichnis einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Integrationsverzeichnis mit aiw_components und installieren sie mit aiw_add @ai-website/integration-directory. Nenn im Prompt die Variante, etwa variant="catalog", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert