Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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 &ldquo;{query}&rdquo;</> : null}
{activeCategory ? <> in {formatIntegrationType(activeCategory)}</> : null}
</p>
) : (
<div>
{filtered.map((integration) => (
<IntegrationRow
key={integration.type}
integration={integration}
IconComponent={blockTypeToIconMap[integration.type]}
/>
))}
</div>
)}
</div>
)
}