d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
import { ChipInput, Search } from '@sim/emcn'
|
|
import { debounce, useQueryStates } from 'nuqs'
|
|
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
|
|
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
|
|
import {
|
|
integrationsParsers,
|
|
integrationsUrlKeys,
|
|
} from '@/app/(landing)/integrations/search-params'
|
|
|
|
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
|
const SEARCH_DEBOUNCE_MS = 300
|
|
|
|
const PILL_BASE =
|
|
'rounded-[5px] border border-[var(--border-1)] px-[9px] py-0.5 text-small text-[var(--text-primary)] transition-colors' as const
|
|
const PILL_ACTIVE = 'bg-[var(--surface-active)]' as const
|
|
const PILL_INACTIVE = 'hover:bg-[var(--surface-hover)]' as const
|
|
|
|
interface IntegrationGridProps {
|
|
integrations: readonly Integration[]
|
|
}
|
|
|
|
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
|
const [{ q: query, category }, setParams] = useQueryStates(
|
|
integrationsParsers,
|
|
integrationsUrlKeys
|
|
)
|
|
const activeCategory = category || null
|
|
|
|
// Category facets and a per-integration lowercased search index, derived once
|
|
// from the (stable) integration list instead of rebuilt on every keystroke.
|
|
// The index keeps each searchable field as its own entry so matching stays
|
|
// identical to a per-field `includes` (no cross-field boundary matches).
|
|
const { availableCategories, searchIndex } = useMemo(() => {
|
|
const counts = new Map<string, number>()
|
|
const searchIndex = new Map<string, string[]>()
|
|
for (const i of integrations) {
|
|
if (i.integrationType) {
|
|
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
|
|
}
|
|
searchIndex.set(i.type, [
|
|
i.name.toLowerCase(),
|
|
i.description.toLowerCase(),
|
|
...i.operations.flatMap((op) => [op.name.toLowerCase(), op.description.toLowerCase()]),
|
|
...i.triggers.map((t) => t.name.toLowerCase()),
|
|
])
|
|
}
|
|
return {
|
|
availableCategories: Array.from(counts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([key]) => key),
|
|
searchIndex,
|
|
}
|
|
}, [integrations])
|
|
|
|
const q = query.trim().toLowerCase()
|
|
const filtered = useMemo(
|
|
() =>
|
|
integrations.filter((i) => {
|
|
if (activeCategory && i.integrationType !== activeCategory) return false
|
|
if (!q) return true
|
|
return searchIndex.get(i.type)?.some((field) => field.includes(q)) ?? false
|
|
}),
|
|
[integrations, searchIndex, q, activeCategory]
|
|
)
|
|
|
|
return (
|
|
<div>
|
|
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
|
|
<div className='max-w-[480px] flex-1'>
|
|
<ChipInput
|
|
icon={Search}
|
|
type='search'
|
|
placeholder='Search integrations, tools, or triggers…'
|
|
value={query}
|
|
onChange={(e) =>
|
|
setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) })
|
|
}
|
|
aria-label='Search integrations'
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
|
<button
|
|
type='button'
|
|
onClick={() => setParams({ category: '' })}
|
|
className={`${PILL_BASE} ${activeCategory === null ? PILL_ACTIVE : PILL_INACTIVE}`}
|
|
>
|
|
All
|
|
</button>
|
|
{availableCategories.map((cat) => (
|
|
<button
|
|
key={cat}
|
|
type='button'
|
|
onClick={() => setParams({ category: activeCategory === cat ? '' : cat })}
|
|
className={`${PILL_BASE} ${activeCategory === cat ? PILL_ACTIVE : PILL_INACTIVE}`}
|
|
>
|
|
{formatIntegrationType(cat)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className='h-px w-full bg-[var(--border)]' />
|
|
|
|
{filtered.length === 0 ? (
|
|
<p className='py-12 text-center text-[15px] text-[var(--text-muted)]'>
|
|
No integrations found
|
|
{query ? <> for “{query}”</> : null}
|
|
{activeCategory ? <> in {formatIntegrationType(activeCategory)}</> : null}
|
|
</p>
|
|
) : (
|
|
<div>
|
|
{filtered.map((integration) => (
|
|
<IntegrationRow
|
|
key={integration.type}
|
|
integration={integration}
|
|
IconComponent={blockTypeToIconMap[integration.type]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|