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.
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
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 30 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle IntegrationDirectoryProps aus components/ui/integration-directory.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalIntegrationDirectoryVariantcatalogdetaila-zmanage
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodeitemsPflichtIntegrationItem[]categoriesoptionalstring[]Order of the categories, derived from the items otherwise
featuredoptionalstring[]a-z: names shown large on top
detailoptionalIntegrationDetaildetail: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| search | Englisch Search integrations | Deutsch Integrationen durchsuchen |
| searchPh | Englisch Search by name or what it does | Deutsch Nach Name oder Aufgabe suchen |
| all | Englisch All | Deutsch Alle |
| categories | Englisch Categories | Deutsch Kategorien |
| results | Englisch {n} integrations | Deutsch {n} Integrationen |
| result1 | Englisch 1 integration | Deutsch 1 Integration |
| empty | Englisch Nothing matches “{q}” yet. | Deutsch Zu „{q}“ passt noch nichts. |
| emptyHint | Englisch Tell us what you use, we build the most requested ones first. | Deutsch Sag uns, was du nutzt. Die meistgewünschten bauen wir zuerst. |
| clear | Englisch Clear search | Deutsch Suche leeren |
| popular | Englisch Popular | Deutsch Beliebt |
| new | Englisch New | Deutsch Neu |
| beta | Englisch Beta | Deutsch Beta |
| connect | Englisch Connect | Deutsch Verbinden |
| connecting | Englisch Connecting | Deutsch Wird verbunden |
| connected | Englisch Connected | Deutsch Verbunden |
| disconnect | Englisch Disconnect | Deutsch Trennen |
| configure | Englisch Settings | Deutsch Einstellungen |
| synced | Englisch Synced just now | Deutsch Gerade abgeglichen |
| what | Englisch What it does | Deutsch Was es kann |
| setup | Englisch Set up in {n} steps | Deutsch In {n} Schritten eingerichtet |
| permissions | Englisch What it can see | Deutsch Worauf es zugreift |
| about | Englisch About | Deutsch Steckbrief |
| related | Englisch Works well with | Deutsch Passt gut zu |
| jump | Englisch Jump to letter | Deutsch Zum Buchstaben springen |
| featured | Englisch Featured | Deutsch Empfohlen |
| tabAll | Englisch All | Deutsch Alle |
| tabOn | Englisch Connected | Deutsch Verbunden |
| available | Englisch Available | Deutsch Verfügbar |
| noneOn | Englisch Nothing connected yet. Switch on what you use below. | Deutsch Noch nichts verbunden. Schalte unten ein, was du nutzt. |
| open | Englisch 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
'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.