mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19442f19e2 | ||
|
|
951c8fd5e9 | ||
|
|
4a34ac3015 | ||
|
|
224ff5dacc | ||
|
|
cb3cc378b8 | ||
|
|
a64afac075 | ||
|
|
e270756886 | ||
|
|
6d7121110e | ||
|
|
1731a4d7f0 | ||
|
|
8d84c30556 | ||
|
|
e796dfee0d | ||
|
|
1eb85dd66f | ||
|
|
0be9303345 | ||
|
|
fa181f0155 |
@@ -19,7 +19,7 @@ When the user asks you to create a block:
|
||||
```typescript
|
||||
import { {ServiceName}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const {ServiceName}Block: BlockConfig = {
|
||||
@@ -29,6 +29,8 @@ export const {ServiceName}Block: BlockConfig = {
|
||||
longDescription: 'Detailed description for docs',
|
||||
docsLink: 'https://docs.sim.ai/tools/{service}',
|
||||
category: 'tools', // 'tools' | 'blocks' | 'triggers'
|
||||
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
|
||||
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
|
||||
bgColor: '#HEXCOLOR', // Brand color
|
||||
icon: {ServiceName}Icon,
|
||||
|
||||
@@ -629,7 +631,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
```typescript
|
||||
import { ServiceIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const ServiceBlock: BlockConfig = {
|
||||
@@ -639,6 +641,8 @@ export const ServiceBlock: BlockConfig = {
|
||||
longDescription: 'Full description for documentation...',
|
||||
docsLink: 'https://docs.sim.ai/tools/service',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.DeveloperTools,
|
||||
tags: ['oauth', 'api'],
|
||||
bgColor: '#FF6B6B',
|
||||
icon: ServiceIcon,
|
||||
authMode: AuthMode.OAuth,
|
||||
@@ -796,6 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
|
||||
|
||||
## Checklist Before Finishing
|
||||
|
||||
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
|
||||
- [ ] `tags` array includes all applicable `IntegrationTag` values
|
||||
- [ ] All subBlocks have `id`, `title` (except switch), and `type`
|
||||
- [ ] Conditions use correct syntax (field, value, not, and)
|
||||
- [ ] DependsOn set for fields that need other values
|
||||
|
||||
@@ -113,7 +113,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
|
||||
```typescript
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const {Service}Block: BlockConfig = {
|
||||
@@ -123,6 +123,8 @@ export const {Service}Block: BlockConfig = {
|
||||
longDescription: '...',
|
||||
docsLink: 'https://docs.sim.ai/tools/{service}',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
|
||||
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
|
||||
bgColor: '#HEXCOLOR',
|
||||
icon: {Service}Icon,
|
||||
authMode: AuthMode.OAuth, // or AuthMode.ApiKey
|
||||
@@ -410,6 +412,8 @@ If creating V2 versions (API-aligned outputs):
|
||||
|
||||
### Block
|
||||
- [ ] Created `blocks/blocks/{service}.ts`
|
||||
- [ ] Set `integrationType` to the correct `IntegrationType` enum value
|
||||
- [ ] Set `tags` array with all applicable `IntegrationTag` values
|
||||
- [ ] Defined operation dropdown with all operations
|
||||
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
|
||||
- [ ] Added conditional fields per operation
|
||||
|
||||
@@ -4140,7 +4140,7 @@ export function IncidentioIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function InfisicalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 273 182' xmlns='http://www.w3.org/2000/svg'>
|
||||
<svg {...props} viewBox='20 25 233 132' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m191.6 39.4c-20.3 0-37.15 13.21-52.9 30.61-12.99-16.4-29.8-30.61-51.06-30.61-27.74 0-50.44 23.86-50.44 51.33 0 26.68 21.43 51.8 48.98 51.8 20.55 0 37.07-13.86 51.32-31.81 12.69 16.97 29.1 31.41 53.2 31.41 27.13 0 49.85-22.96 49.85-51.4 0-27.12-20.44-51.33-48.95-51.33zm-104.3 77.94c-14.56 0-25.51-12.84-25.51-26.07 0-13.7 10.95-28.29 25.51-28.29 14.93 0 25.71 11.6 37.6 27.34-11.31 15.21-22.23 27.02-37.6 27.02zm104.4 0.25c-15 0-25.28-11.13-37.97-27.37 12.69-16.4 22.01-27.24 37.59-27.24 14.97 0 24.79 13.25 24.79 27.26 0 13-10.17 27.35-24.41 27.35z'
|
||||
fill='currentColor'
|
||||
@@ -4253,7 +4253,7 @@ export function ZoomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
fill='currentColor'
|
||||
width='800px'
|
||||
height='800px'
|
||||
viewBox='0 0 32 32'
|
||||
viewBox='-1 9.5 34 13'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Manage users and groups in Azure AD (Microsoft Entra ID)
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
<BlockInfoCard
|
||||
type="microsoft_ad"
|
||||
color="#0078D4"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Manage users and groups in Okta
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
<BlockInfoCard
|
||||
type="okta"
|
||||
color="#191919"
|
||||
/>
|
||||
@@ -29,6 +29,7 @@ In Sim, the Okta integration enables your agents to automate identity management
|
||||
If you encounter issues with the Okta integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.
|
||||
|
||||
24
apps/sim/app/(auth)/login/loading.tsx
Normal file
24
apps/sim/app/(auth)/login/loading.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function LoginLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[80px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-[24px] flex w-full gap-[12px]'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[200px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -86,6 +87,9 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
@@ -115,19 +119,6 @@ export default function LoginPage({
|
||||
: null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && forgotPasswordOpen) {
|
||||
handleForgotPassword()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [forgotPasswordEmail, forgotPasswordOpen])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
@@ -178,6 +169,21 @@ export default function LoginPage({
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
email,
|
||||
@@ -185,6 +191,11 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
@@ -460,6 +471,20 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className='text-red-400 text-xs'>
|
||||
<p>{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
@@ -540,45 +565,51 @@ export default function LoginPage({
|
||||
<ModalContent className='dark' size='sm'>
|
||||
<ModalHeader>Reset Password</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</ModalDescription>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleForgotPassword()
|
||||
}}
|
||||
>
|
||||
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</ModalDescription>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
21
apps/sim/app/(auth)/oauth/consent/loading.tsx
Normal file
21
apps/sim/app/(auth)/oauth/consent/loading.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function OAuthConsentLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[38px] w-[220px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[56px] w-full rounded-[8px]' />
|
||||
<Skeleton className='mt-[16px] h-[120px] w-full rounded-[8px]' />
|
||||
<div className='mt-[24px] flex w-full max-w-[410px] gap-[12px]'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/(auth)/reset-password/loading.tsx
Normal file
16
apps/sim/app/(auth)/reset-password/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function ResetPasswordLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[160px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
apps/sim/app/(auth)/signup/loading.tsx
Normal file
28
apps/sim/app/(auth)/signup/loading.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function SignupLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[100px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-[16px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-[24px] flex w-full gap-[12px]'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[220px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useMemo, useState } from 'react'
|
||||
import { Suspense, useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -90,6 +91,9 @@ function SignupFormContent({
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const redirectUrl = useMemo(
|
||||
@@ -245,6 +249,21 @@ function SignupFormContent({
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email: emailValue,
|
||||
@@ -252,6 +271,11 @@ function SignupFormContent({
|
||||
name: sanitizedName,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Signup error:', ctx.error)
|
||||
const errorMessage: string[] = ['Failed to create account']
|
||||
@@ -453,6 +477,20 @@ function SignupFormContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className='text-red-400 text-xs'>
|
||||
<p>{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
|
||||
16
apps/sim/app/(auth)/sso/loading.tsx
Normal file
16
apps/sim/app/(auth)/sso/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function SSOLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[260px] rounded-[4px]' />
|
||||
<div className='mt-[32px] w-full space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-[24px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/sim/app/(auth)/verify/loading.tsx
Normal file
12
apps/sim/app/(auth)/verify/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function VerifyLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[180px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[14px] w-[300px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[32px] h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -460,7 +460,7 @@ function TrustStrip() {
|
||||
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-[8px] border border-[#2A2A2A] sm:grid-cols-3 md:mx-8'>
|
||||
{/* SOC 2 + HIPAA combined */}
|
||||
<Link
|
||||
href='https://trust.delve.co/sim-studio'
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
|
||||
@@ -612,7 +612,9 @@ export default function Enterprise() {
|
||||
Ready for growth?
|
||||
</p>
|
||||
<Link
|
||||
href='/contact'
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Book a demo
|
||||
|
||||
@@ -23,7 +23,7 @@ const PRODUCT_LINKS: FooterItem[] = [
|
||||
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: 'Templates', href: '/templates' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
|
||||
@@ -78,7 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'Self hosting · Dedicated support',
|
||||
],
|
||||
cta: { label: 'Book a demo', href: '/contact' },
|
||||
cta: { label: 'Book a demo', href: 'https://form.typeform.com/to/jqCO12pF' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -125,12 +125,14 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
{isEnterprise ? (
|
||||
<Link
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
</a>
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import {
|
||||
@@ -33,12 +33,7 @@ import {
|
||||
* enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials).
|
||||
*/
|
||||
export default async function Landing() {
|
||||
const allPosts = await getAllPostMeta()
|
||||
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
|
||||
const recentPosts = allPosts.filter((p) => p !== featuredPost).slice(0, 4)
|
||||
const blogPosts = [featuredPost, ...recentPosts]
|
||||
.filter(Boolean)
|
||||
.map((p) => ({ slug: p.slug, title: p.title, ogImage: p.ogImage }))
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||
|
||||
59
apps/sim/app/(landing)/blog/[slug]/loading.tsx
Normal file
59
apps/sim/app/(landing)/blog/[slug]/loading.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return (
|
||||
<article className='w-full'>
|
||||
{/* Header area */}
|
||||
<div className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
{/* Back link */}
|
||||
<div className='mb-6'>
|
||||
<Skeleton className='h-[16px] w-[60px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
{/* Image + title row */}
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
{/* Image */}
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<Skeleton className='aspect-[450/360] w-full rounded-lg bg-[#2A2A2A]' />
|
||||
</div>
|
||||
{/* Title + author */}
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<div>
|
||||
<Skeleton className='h-[48px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-[8px] h-[48px] w-[80%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[24px] w-[24px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[32px] rounded-[6px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<Skeleton className='mt-8 h-[1px] w-full bg-[#2A2A2A] sm:mt-12' />
|
||||
{/* Date + description */}
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<Skeleton className='h-[16px] w-[120px] flex-shrink-0 rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='flex-1 space-y-[8px]'>
|
||||
<Skeleton className='h-[20px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[20px] w-[70%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Article body */}
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12'>
|
||||
<div className='space-y-[16px]'>
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-[24px] h-[24px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
25
apps/sim/app/(landing)/blog/authors/[id]/loading.tsx
Normal file
25
apps/sim/app/(landing)/blog/authors/[id]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_POST_COUNT = 4
|
||||
|
||||
export default function AuthorLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<div className='mb-6 flex items-center gap-3'>
|
||||
<Skeleton className='h-[40px] w-[40px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{Array.from({ length: SKELETON_POST_COUNT }).map((_, i) => (
|
||||
<div key={i} className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
|
||||
<Skeleton className='h-[160px] w-full rounded-none bg-[#2A2A2A]' />
|
||||
<div className='p-3'>
|
||||
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function StudioLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function StudioLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
const orgJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
@@ -34,7 +36,7 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<header>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer />
|
||||
|
||||
29
apps/sim/app/(landing)/blog/loading.tsx
Normal file
29
apps/sim/app/(landing)/blog/loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_CARD_COUNT = 6
|
||||
|
||||
export default function BlogLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
|
||||
<Skeleton className='h-[48px] w-[100px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-3 h-[18px] w-[420px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
|
||||
<div key={i} className='flex flex-col overflow-hidden rounded-xl border border-[#2A2A2A]'>
|
||||
<Skeleton className='aspect-video w-full rounded-none bg-[#2A2A2A]' />
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<Skeleton className='mb-2 h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-1 h-[20px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mb-3 h-[14px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Skeleton className='h-[16px] w-[16px] rounded-full bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
20
apps/sim/app/(landing)/blog/tags/loading.tsx
Normal file
20
apps/sim/app/(landing)/blog/tags/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_TAG_COUNT = 12
|
||||
|
||||
export default function TagsLoading() {
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{Array.from({ length: SKELETON_TAG_COUNT }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-[30px] rounded-full bg-[#2A2A2A]'
|
||||
style={{ width: `${60 + (i % 4) * 24}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(landing)/components/external-redirect.tsx
Normal file
18
apps/sim/app/(landing)/components/external-redirect.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ExternalRedirectProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Redirects to an external URL when it is configured via an environment variable. */
|
||||
export default function ExternalRedirect({ url }: ExternalRedirectProps) {
|
||||
useEffect(() => {
|
||||
if (url?.startsWith('http')) {
|
||||
window.location.href = url
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -6,7 +6,11 @@ export default function ComplianceBadges() {
|
||||
return (
|
||||
<div className='mt-[6px] flex items-center gap-[12px]'>
|
||||
{/* SOC2 badge */}
|
||||
<Link href='https://trust.delve.co/sim-studio' target='_blank' rel='noopener noreferrer'>
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Image
|
||||
src='/footer/soc2.png'
|
||||
alt='SOC2 Compliant'
|
||||
@@ -18,7 +22,11 @@ export default function ComplianceBadges() {
|
||||
/>
|
||||
</Link>
|
||||
{/* HIPAA badge */}
|
||||
<Link href='https://trust.delve.co/sim-studio' target='_blank' rel='noopener noreferrer'>
|
||||
<Link
|
||||
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<HIPAABadgeIcon className='h-[54px] w-[54px]' />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Background from '@/app/(landing)/components/background/background'
|
||||
import ExternalRedirect from '@/app/(landing)/components/external-redirect'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Hero from '@/app/(landing)/components/hero/hero'
|
||||
import Integrations from '@/app/(landing)/components/integrations/integrations'
|
||||
@@ -20,4 +21,5 @@ export {
|
||||
Footer,
|
||||
StructuredData,
|
||||
LegalLayout,
|
||||
ExternalRedirect,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
@@ -7,11 +8,13 @@ interface LegalLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function LegalLayout({ title, children }: LegalLayoutProps) {
|
||||
export default async function LegalLayout({ title, children }: LegalLayoutProps) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<main className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
<header>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<div className='mx-auto max-w-[800px] px-6 pt-[60px] pb-[80px] sm:px-12'>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/templat
|
||||
import { IntegrationIcon } from '../components/integration-icon'
|
||||
import { blockTypeToIconMap } from '../data/icon-mapping'
|
||||
import integrations from '../data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from '../data/popular-workflows'
|
||||
import type { AuthType, FAQItem, Integration } from '../data/types'
|
||||
import { IntegrationFAQ } from './components/integration-faq'
|
||||
import { TemplateCardButton } from './components/template-card-button'
|
||||
@@ -14,44 +13,52 @@ const allIntegrations = integrations as Integration[]
|
||||
const INTEGRATION_COUNT = allIntegrations.length
|
||||
|
||||
/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */
|
||||
const byName = new Map(allIntegrations.map((i) => [i.name, i]))
|
||||
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
|
||||
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
|
||||
|
||||
/** Returns workflow pairs that feature the given integration on either side. */
|
||||
function getPairsFor(name: string) {
|
||||
return POPULAR_WORKFLOWS.filter((p) => p.from === name || p.to === name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` related integration slugs.
|
||||
*
|
||||
* Scoring:
|
||||
* +100 — integration appears as a workflow pair partner (explicit editorial signal)
|
||||
* +N — N operation names shared with the current integration (semantic similarity)
|
||||
* Scoring (additive):
|
||||
* +3 per shared operation name — strongest signal (same capability)
|
||||
* +2 per shared operation word — weaker signal (e.g. both have "create" ops)
|
||||
* +1 same auth type — comparable setup experience
|
||||
*
|
||||
* This means genuine partners always rank first; operation-similar integrations
|
||||
* (e.g. Slack → Teams → Discord for "Send Message") fill the rest organically.
|
||||
* Every integration gets a score, so the sidebar always has suggestions.
|
||||
* Ties are broken by alphabetical slug order for determinism.
|
||||
*/
|
||||
function getRelatedSlugs(
|
||||
name: string,
|
||||
slug: string,
|
||||
operations: Integration['operations'],
|
||||
authType: AuthType,
|
||||
limit = 6
|
||||
): string[] {
|
||||
const partners = new Set(getPairsFor(name).map((p) => (p.from === name ? p.to : p.from)))
|
||||
const currentOps = new Set(operations.map((o) => o.name.toLowerCase()))
|
||||
const currentOpNames = new Set(operations.map((o) => o.name.toLowerCase()))
|
||||
const currentOpWords = new Set(
|
||||
operations.flatMap((o) =>
|
||||
o.name
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 3)
|
||||
)
|
||||
)
|
||||
|
||||
return allIntegrations
|
||||
.filter((i) => i.slug !== slug)
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
score:
|
||||
(partners.has(i.name) ? 100 : 0) +
|
||||
i.operations.filter((o) => currentOps.has(o.name.toLowerCase())).length,
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((i) => {
|
||||
const sharedNames = i.operations.filter((o) =>
|
||||
currentOpNames.has(o.name.toLowerCase())
|
||||
).length
|
||||
const sharedWords = i.operations.filter((o) =>
|
||||
o.name
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.some((w) => w.length > 3 && currentOpWords.has(w))
|
||||
).length
|
||||
const sameAuth = i.authType === authType ? 1 : 0
|
||||
return { slug: i.slug, score: sharedNames * 3 + sharedWords * 2 + sameAuth }
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
|
||||
.slice(0, limit)
|
||||
.map(({ slug: s }) => s)
|
||||
}
|
||||
@@ -70,7 +77,6 @@ function buildFAQs(integration: Integration): FAQItem[] {
|
||||
const { name, description, operations, triggers, authType } = integration
|
||||
const topOps = operations.slice(0, 5)
|
||||
const topOpNames = topOps.map((o) => o.name)
|
||||
const pairs = getPairsFor(name)
|
||||
const authStep = AUTH_STEP[authType]
|
||||
|
||||
const faqs: FAQItem[] = [
|
||||
@@ -89,6 +95,10 @@ function buildFAQs(integration: Integration): FAQItem[] {
|
||||
question: `How do I connect ${name} to Sim?`,
|
||||
answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open a new workflow. (3) Drag a ${name} block onto the canvas. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your automation is live.`,
|
||||
},
|
||||
{
|
||||
question: `Can I use ${name} as a tool inside an AI agent in Sim?`,
|
||||
answer: `Yes — this is one of Sim's core capabilities. Instead of hard-coding when and how ${name} is used, you give an AI agent access to ${name} tools and describe the goal in plain language. The agent decides which tools to call, in what order, and how to handle the results. This means your automation adapts to context rather than breaking when inputs change.`,
|
||||
},
|
||||
...(topOpNames.length >= 2
|
||||
? [
|
||||
{
|
||||
@@ -97,19 +107,15 @@ function buildFAQs(integration: Integration): FAQItem[] {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pairs.length > 0
|
||||
? [
|
||||
{
|
||||
question: `Can I connect ${name} to ${pairs[0].from === name ? pairs[0].to : pairs[0].from} with Sim?`,
|
||||
answer: `Yes. ${pairs[0].description} In Sim, you set this up by adding both a ${name} block and a ${pairs[0].from === name ? pairs[0].to : pairs[0].from} block to the same workflow and connecting them through an AI agent that orchestrates the logic between them.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(triggers.length > 0
|
||||
? [
|
||||
{
|
||||
question: `Can ${name} trigger a Sim workflow automatically?`,
|
||||
answer: `Yes. ${name} supports ${triggers.length} webhook trigger${triggers.length === 1 ? '' : 's'} that can instantly start a Sim workflow: ${triggers.map((t) => t.name).join(', ')}. No polling needed — the workflow fires the moment the event occurs in ${name}.`,
|
||||
question: `How do I trigger a Sim workflow from ${name} automatically?`,
|
||||
answer: `In your Sim workflow, switch the ${name} block to Trigger mode and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
|
||||
},
|
||||
{
|
||||
question: `What data does Sim receive when a ${name} event triggers a workflow?`,
|
||||
answer: `When ${name} fires a webhook, Sim receives the full event payload that ${name} sends — typically the record or object that changed, along with metadata like the event type and timestamp. Inside your workflow, every field from that payload is available as a variable you can pass to AI agents, conditions, or other integrations.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -190,11 +196,10 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
|
||||
const IconComponent = blockTypeToIconMap[integration.type]
|
||||
const faqs = buildFAQs(integration)
|
||||
const relatedSlugs = getRelatedSlugs(name, slug, operations)
|
||||
const relatedSlugs = getRelatedSlugs(slug, operations, authType)
|
||||
const relatedIntegrations = relatedSlugs
|
||||
.map((s) => bySlug.get(s))
|
||||
.filter((i): i is Integration => i !== undefined)
|
||||
const featuredPairs = getPairsFor(name)
|
||||
const baseType = integration.type.replace(/_v\d+$/, '')
|
||||
const matchingTemplates = TEMPLATES.filter(
|
||||
(t) =>
|
||||
@@ -420,15 +425,18 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
{triggers.length > 0 && (
|
||||
<section aria-labelledby='triggers-heading'>
|
||||
<h2 id='triggers-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Triggers
|
||||
Real-time triggers
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
These events in {name} can automatically start a Sim workflow — no polling
|
||||
required.
|
||||
<p className='mb-4 text-[#999] text-[14px] leading-relaxed'>
|
||||
Connect a {name} webhook to Sim and your workflow fires the instant an event
|
||||
happens — no polling, no delay. Sim receives the full event payload and makes
|
||||
every field available as a variable inside your workflow.
|
||||
</p>
|
||||
|
||||
{/* Event cards */}
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
|
||||
aria-label={`${name} triggers`}
|
||||
aria-label={`${name} trigger events`}
|
||||
>
|
||||
{triggers.map((trigger) => (
|
||||
<li
|
||||
@@ -447,7 +455,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
>
|
||||
<polygon points='13 2 3 14 12 14 11 22 21 10 12 10 13 2' />
|
||||
</svg>
|
||||
Trigger
|
||||
Event
|
||||
</span>
|
||||
</div>
|
||||
<p className='font-[500] text-[#ECECEC] text-[13px]'>{trigger.name}</p>
|
||||
@@ -462,73 +470,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Popular workflows featuring this integration */}
|
||||
{featuredPairs.length > 0 && (
|
||||
<section aria-labelledby='workflows-heading'>
|
||||
<h2 id='workflows-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
|
||||
Popular workflows with {name}
|
||||
</h2>
|
||||
<p className='mb-6 text-[#999] text-[14px]'>
|
||||
Common automation patterns teams build on Sim using {name}.
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-4 sm:grid-cols-2'
|
||||
aria-label='Popular workflow combinations'
|
||||
>
|
||||
{featuredPairs.map(({ from, to, headline, description: desc }) => {
|
||||
const fromInt = byName.get(from)
|
||||
const toInt = byName.get(to)
|
||||
const FromIcon = fromInt ? blockTypeToIconMap[fromInt.type] : undefined
|
||||
const ToIcon = toInt ? blockTypeToIconMap[toInt.type] : undefined
|
||||
const fromBg = fromInt?.bgColor ?? '#6B7280'
|
||||
const toBg = toInt?.bgColor ?? '#6B7280'
|
||||
|
||||
return (
|
||||
<li key={`${from}-${to}`}>
|
||||
<div className='h-full rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<div className='mb-3 flex items-center gap-2 text-[12px]'>
|
||||
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
|
||||
{FromIcon && (
|
||||
<IntegrationIcon
|
||||
bgColor={fromBg}
|
||||
name={from}
|
||||
Icon={FromIcon}
|
||||
as='span'
|
||||
className='h-3.5 w-3.5 rounded-[2px]'
|
||||
iconClassName='h-2.5 w-2.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{from}
|
||||
</span>
|
||||
<span className='text-[#555]' aria-hidden='true'>
|
||||
→
|
||||
</span>
|
||||
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
|
||||
{ToIcon && (
|
||||
<IntegrationIcon
|
||||
bgColor={toBg}
|
||||
name={to}
|
||||
Icon={ToIcon}
|
||||
as='span'
|
||||
className='h-3.5 w-3.5 rounded-[2px]'
|
||||
iconClassName='h-2.5 w-2.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{to}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mb-1 font-[500] text-[#ECECEC] text-[14px]'>{headline}</p>
|
||||
<p className='text-[#999] text-[13px] leading-relaxed'>{desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Workflow templates */}
|
||||
{matchingTemplates.length > 0 && (
|
||||
<section aria-labelledby='templates-heading'>
|
||||
@@ -539,7 +480,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
Ready-to-use workflows featuring {name}. Click any to build it instantly.
|
||||
</p>
|
||||
<ul
|
||||
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
|
||||
className='grid grid-cols-1 gap-4 sm:grid-cols-2'
|
||||
aria-label='Workflow templates'
|
||||
>
|
||||
{matchingTemplates.map((template) => {
|
||||
@@ -551,34 +492,49 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
return (
|
||||
<li key={template.title}>
|
||||
<TemplateCardButton prompt={template.prompt}>
|
||||
{/* Integration icons */}
|
||||
<div className='mb-3 flex items-center gap-1.5'>
|
||||
{allTypes.map((bt) => {
|
||||
const int = byType.get(bt)
|
||||
{/* Integration pills row */}
|
||||
<div className='mb-3 flex flex-wrap items-center gap-1.5 text-[12px]'>
|
||||
{allTypes.map((bt, idx) => {
|
||||
// Templates may use unversioned keys (e.g. "notion") while the
|
||||
// icon map has versioned keys ("notion_v2") — fall back to _v2.
|
||||
const resolvedBt = byType.get(bt)
|
||||
? bt
|
||||
: byType.get(`${bt}_v2`)
|
||||
? `${bt}_v2`
|
||||
: bt
|
||||
const int = byType.get(resolvedBt)
|
||||
const intName = int?.name ?? bt
|
||||
return (
|
||||
<IntegrationIcon
|
||||
key={bt}
|
||||
bgColor={int?.bgColor ?? '#6B7280'}
|
||||
name={intName}
|
||||
Icon={blockTypeToIconMap[bt]}
|
||||
className='h-6 w-6 rounded-[5px]'
|
||||
iconClassName='h-3.5 w-3.5'
|
||||
fallbackClassName='text-[9px]'
|
||||
title={intName}
|
||||
aria-label={intName}
|
||||
/>
|
||||
<span key={bt} className='inline-flex items-center gap-1.5'>
|
||||
{idx > 0 && (
|
||||
<span className='text-[#555]' aria-hidden='true'>
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
|
||||
<IntegrationIcon
|
||||
bgColor={int?.bgColor ?? '#6B7280'}
|
||||
name={intName}
|
||||
Icon={blockTypeToIconMap[resolvedBt]}
|
||||
as='span'
|
||||
className='h-3.5 w-3.5 rounded-[2px]'
|
||||
iconClassName='h-2.5 w-2.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{intName}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className='mb-3 font-[500] text-[#ECECEC] text-[13px] leading-snug'>
|
||||
<p className='mb-1 font-[500] text-[#ECECEC] text-[14px]'>
|
||||
{template.title}
|
||||
</p>
|
||||
|
||||
<span className='text-[#555] text-[12px] transition-colors group-hover:text-[#999]'>
|
||||
<p className='mt-3 text-[#555] text-[13px] transition-colors group-hover:text-[#999]'>
|
||||
Try this workflow →
|
||||
</span>
|
||||
</p>
|
||||
</TemplateCardButton>
|
||||
</li>
|
||||
)
|
||||
@@ -660,40 +616,34 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
<dd className='text-[#ECECEC]'>Free to start</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='mt-5 flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[#1C1C1C] text-[13px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Get started free
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Docs */}
|
||||
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
|
||||
<h3 className='mb-2 font-[500] text-[#ECECEC] text-[14px]'>Documentation</h3>
|
||||
<p className='mb-4 text-[#999] text-[13px] leading-relaxed'>
|
||||
Full API reference, authentication setup, and usage examples for {name}.
|
||||
</p>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1.5 text-[#999] text-[13px] transition-colors hover:text-[#ECECEC]'
|
||||
>
|
||||
docs.sim.ai
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
<div className='mt-5 flex flex-col gap-2'>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[#1C1C1C] text-[13px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
Get started free
|
||||
</a>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-[32px] w-full items-center justify-center gap-1.5 rounded-[5px] border border-[#3d3d3d] font-[430] font-season text-[#ECECEC] text-[13px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
View docs
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related integrations — internal linking for SEO */}
|
||||
@@ -738,6 +688,43 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
aria-labelledby='cta-heading'
|
||||
className='mt-20 rounded-xl border border-[#2A2A2A] bg-[#242424] p-8 text-center sm:p-12'
|
||||
>
|
||||
{/* Logo pair: Sim × Integration */}
|
||||
<div className='mx-auto mb-6 flex items-center justify-center gap-3'>
|
||||
<img
|
||||
src='/brandbook/logo/small.png'
|
||||
alt='Sim'
|
||||
className='h-14 w-14 shrink-0 rounded-xl'
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='h-px w-5 bg-[#3d3d3d]' aria-hidden='true' />
|
||||
<span
|
||||
className='flex h-7 w-7 items-center justify-center rounded-full border border-[#3d3d3d]'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<svg
|
||||
className='h-3.5 w-3.5 text-[#666]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
>
|
||||
<path d='M5 12h14' />
|
||||
<path d='M12 5v14' />
|
||||
</svg>
|
||||
</span>
|
||||
<span className='h-px w-5 bg-[#3d3d3d]' aria-hidden='true' />
|
||||
</div>
|
||||
<IntegrationIcon
|
||||
bgColor={bgColor}
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='h-14 w-14 rounded-xl'
|
||||
iconClassName='h-7 w-7'
|
||||
fallbackClassName='text-[22px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
id='cta-heading'
|
||||
className='mb-3 font-[500] text-[#ECECEC] text-[28px] sm:text-[34px]'
|
||||
|
||||
@@ -6,54 +6,132 @@ import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mappi
|
||||
import type { Integration } from '@/app/(landing)/integrations/data/types'
|
||||
import { IntegrationCard } from './integration-card'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
ai: 'AI',
|
||||
analytics: 'Analytics',
|
||||
automation: 'Automation',
|
||||
communication: 'Communication',
|
||||
crm: 'CRM',
|
||||
'customer-support': 'Customer Support',
|
||||
databases: 'Databases',
|
||||
design: 'Design',
|
||||
'developer-tools': 'Developer Tools',
|
||||
documents: 'Documents',
|
||||
ecommerce: 'E-commerce',
|
||||
email: 'Email',
|
||||
'file-storage': 'File Storage',
|
||||
hr: 'HR',
|
||||
media: 'Media',
|
||||
productivity: 'Productivity',
|
||||
'sales-intelligence': 'Sales Intelligence',
|
||||
search: 'Search',
|
||||
security: 'Security',
|
||||
social: 'Social',
|
||||
other: 'Other',
|
||||
} as const
|
||||
|
||||
interface IntegrationGridProps {
|
||||
integrations: Integration[]
|
||||
}
|
||||
|
||||
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const i of integrations) {
|
||||
if (i.integrationType) {
|
||||
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
|
||||
}
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key]) => key)
|
||||
}, [integrations])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let results = integrations
|
||||
|
||||
if (activeCategory) {
|
||||
results = results.filter((i) => i.integrationType === activeCategory)
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return integrations
|
||||
return integrations.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.description.toLowerCase().includes(q) ||
|
||||
i.operations.some(
|
||||
(op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
|
||||
) ||
|
||||
i.triggers.some((t) => t.name.toLowerCase().includes(q))
|
||||
)
|
||||
}, [integrations, query])
|
||||
if (q) {
|
||||
results = results.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.description.toLowerCase().includes(q) ||
|
||||
i.operations.some(
|
||||
(op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
|
||||
) ||
|
||||
i.triggers.some((t) => t.name.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
return results
|
||||
}, [integrations, query, activeCategory])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='relative mb-8 max-w-[480px]'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
<div className='mb-6 flex flex-col gap-4 sm:flex-row sm:items-center'>
|
||||
<div className='relative max-w-[480px] flex-1'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<circle cx={11} cy={11} r={8} />
|
||||
<path d='m21 21-4.35-4.35' />
|
||||
</svg>
|
||||
<Input
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className='pl-9'
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-8 flex flex-wrap gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`rounded-md border px-3 py-1 text-[12px] transition-colors ${
|
||||
activeCategory === null
|
||||
? 'border-[#555] bg-[#333] text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] bg-transparent text-[#999] hover:border-[#3d3d3d] hover:text-[#ECECEC]'
|
||||
}`}
|
||||
>
|
||||
<circle cx={11} cy={11} r={8} />
|
||||
<path d='m21 21-4.35-4.35' />
|
||||
</svg>
|
||||
<Input
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className='pl-9'
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
All
|
||||
</button>
|
||||
{availableCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type='button'
|
||||
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
|
||||
className={`rounded-md border px-3 py-1 text-[12px] transition-colors ${
|
||||
activeCategory === cat
|
||||
? 'border-[#555] bg-[#333] text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] bg-transparent text-[#999] hover:border-[#3d3d3d] hover:text-[#ECECEC]'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className='py-12 text-center text-[#555] text-[15px]'>
|
||||
No integrations found for “{query}”
|
||||
No integrations found
|
||||
{query ? <> for “{query}”</> : null}
|
||||
{activeCategory ? <> in {CATEGORY_LABELS[activeCategory] || activeCategory}</> : null}
|
||||
</p>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isLightBg } from '@/app/(landing)/integrations/data/utils'
|
||||
|
||||
interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
|
||||
bgColor: string
|
||||
@@ -33,9 +32,6 @@ export function IntegrationIcon({
|
||||
as: Tag = 'div',
|
||||
...rest
|
||||
}: IntegrationIconProps) {
|
||||
const isLight = isLightBg(bgColor)
|
||||
const fgColor = isLight ? 'text-[#1C1C1C]' : 'text-white'
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cn('flex shrink-0 items-center justify-center', className)}
|
||||
@@ -43,9 +39,9 @@ export function IntegrationIcon({
|
||||
{...rest}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={cn(iconClassName, fgColor)} />
|
||||
<Icon className={cn(iconClassName, 'text-white')} />
|
||||
) : (
|
||||
<span className={cn('font-[500] leading-none', fallbackClassName, fgColor)}>
|
||||
<span className={cn('font-[500] text-white leading-none', fallbackClassName)}>
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
|
||||
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export function RequestIntegrationModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [status, setStatus] = useState<SubmitStatus>('idle')
|
||||
|
||||
const [integrationName, setIntegrationName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [useCase, setUseCase] = useState('')
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setIntegrationName('')
|
||||
setEmail('')
|
||||
setUseCase('')
|
||||
setStatus('idle')
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) resetForm()
|
||||
},
|
||||
[resetForm]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!integrationName.trim() || !email.trim()) return
|
||||
|
||||
setStatus('submitting')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/help/integration-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
integrationName: integrationName.trim(),
|
||||
email: email.trim(),
|
||||
useCase: useCase.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Request failed')
|
||||
|
||||
setStatus('success')
|
||||
setTimeout(() => setOpen(false), 1500)
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
},
|
||||
[integrationName, email, useCase]
|
||||
)
|
||||
|
||||
const canSubmit = integrationName.trim() && email.trim() && status === 'idle'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen(true)}
|
||||
className='inline-flex h-[32px] shrink-0 items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Request an integration
|
||||
</button>
|
||||
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Request an Integration</ModalHeader>
|
||||
|
||||
{status === 'success' ? (
|
||||
<ModalBody>
|
||||
<div className='flex flex-col items-center gap-3 py-6 text-center'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-[#33C482]/10'>
|
||||
<svg
|
||||
className='h-5 w-5 text-[#33C482]'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<polyline points='20 6 9 17 4 12' />
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
||||
Request submitted — we'll follow up at{' '}
|
||||
<span className='font-medium'>{email}</span>.
|
||||
</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='integration-name'>Integration name</Label>
|
||||
<Input
|
||||
id='integration-name'
|
||||
placeholder='e.g. Stripe, HubSpot, Snowflake'
|
||||
value={integrationName}
|
||||
onChange={(e) => setIntegrationName(e.target.value)}
|
||||
maxLength={200}
|
||||
autoComplete='off'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='requester-email'>Your email</Label>
|
||||
<Input
|
||||
id='requester-email'
|
||||
type='email'
|
||||
placeholder='you@company.com'
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete='email'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='use-case'>
|
||||
Use case <span className='text-[var(--text-tertiary)]'>(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='use-case'
|
||||
placeholder='What would you automate with this integration?'
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit && status !== 'error'}>
|
||||
{status === 'submitting' ? 'Submitting...' : 'Submit request'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AsanaIcon,
|
||||
AshbyIcon,
|
||||
AttioIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
BrandfetchIcon,
|
||||
@@ -81,6 +82,7 @@ import {
|
||||
HunterIOIcon,
|
||||
ImageIcon,
|
||||
IncidentioIcon,
|
||||
InfisicalIcon,
|
||||
IntercomIcon,
|
||||
JinaAIIcon,
|
||||
JiraIcon,
|
||||
@@ -252,6 +254,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
image_generator: ImageIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
infisical: InfisicalIcon,
|
||||
intercom_v2: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
@@ -269,6 +272,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_ad: AzureIcon,
|
||||
microsoft_dataverse: MicrosoftDataverseIcon,
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,4 +34,6 @@ export interface Integration {
|
||||
triggerCount: number
|
||||
authType: AuthType
|
||||
category: string
|
||||
integrationType?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Utility helpers for the integrations landing pages.
|
||||
* Shared across the listing grid, individual integration cards, and slug pages.
|
||||
*/
|
||||
|
||||
/** bgColor values that are visually light and require dark icon text. */
|
||||
const LIGHT_BG = new Set(['#e0e0e0', '#f5f5f5', '#ffffff', '#ececec', '#f0f0f0'])
|
||||
|
||||
/**
|
||||
* Returns true when `bgColor` is a light color that requires dark foreground text.
|
||||
* Handles gradient strings safely — they always use light foreground (white).
|
||||
*/
|
||||
export function isLightBg(bgColor: string): boolean {
|
||||
return LIGHT_BG.has(bgColor.toLowerCase())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function IntegrationsLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
const orgJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
@@ -34,7 +36,7 @@ export default function IntegrationsLayout({ children }: { children: React.React
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<header>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { IntegrationGrid } from './components/integration-grid'
|
||||
import { RequestIntegrationModal } from './components/request-integration-modal'
|
||||
import { blockTypeToIconMap } from './data/icon-mapping'
|
||||
import integrations from './data/integrations.json'
|
||||
import { POPULAR_WORKFLOWS } from './data/popular-workflows'
|
||||
@@ -138,26 +139,7 @@ export default function IntegrationsPage() {
|
||||
Let us know and we'll prioritize it.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim/issues/new?labels=integration+request&template=integration_request.md'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] shrink-0 items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
|
||||
>
|
||||
Request an integration
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-3 w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
|
||||
<polyline points='15 3 21 3 21 9' />
|
||||
<line x1='10' x2='21' y1='14' y2='3' />
|
||||
</svg>
|
||||
</a>
|
||||
<RequestIntegrationModal />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
59
apps/sim/app/(landing)/privacy/loading.tsx
Normal file
59
apps/sim/app/(landing)/privacy/loading.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function PrivacyLoading() {
|
||||
return (
|
||||
<main className='min-h-screen bg-[#1C1C1C] text-[#ECECEC]'>
|
||||
<div className='flex h-[52px] items-center border-[#2A2A2A] border-b px-6'>
|
||||
<Skeleton className='h-[22px] w-[60px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='ml-auto flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[30px] w-[64px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[30px] w-[80px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[800px] px-6 pt-[60px] pb-[80px] sm:px-12'>
|
||||
<Skeleton className='mx-auto h-[48px] w-[280px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-12 space-y-8'>
|
||||
<div className='space-y-[10px]'>
|
||||
<Skeleton className='h-[15px] w-[180px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[320px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-4 space-y-[10px]'>
|
||||
<Skeleton className='h-[20px] w-[160px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[92%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[260px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[90%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-4 space-y-[8px] pl-6'>
|
||||
<Skeleton className='h-[15px] w-[70%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[60%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[75%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[65%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[300px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[220px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[93%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[80%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { LegalLayout } from '@/app/(landing)/components'
|
||||
import { ExternalRedirect, LegalLayout } from '@/app/(landing)/components'
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
useEffect(() => {
|
||||
const privacyUrl = getEnv('NEXT_PUBLIC_PRIVACY_URL')
|
||||
if (privacyUrl?.startsWith('http')) {
|
||||
window.location.href = privacyUrl
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<LegalLayout title='Privacy Policy'>
|
||||
<ExternalRedirect url={getEnv('NEXT_PUBLIC_PRIVACY_URL') ?? ''} />
|
||||
<section>
|
||||
<p className='mb-4'>Last Updated: October 11, 2025</p>
|
||||
<p>
|
||||
|
||||
52
apps/sim/app/(landing)/terms/loading.tsx
Normal file
52
apps/sim/app/(landing)/terms/loading.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function TermsLoading() {
|
||||
return (
|
||||
<main className='min-h-screen bg-[#1C1C1C] text-[#ECECEC]'>
|
||||
<div className='flex h-[52px] items-center border-[#2A2A2A] border-b px-6'>
|
||||
<Skeleton className='h-[22px] w-[60px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='ml-auto flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[30px] w-[64px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[30px] w-[80px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[800px] px-6 pt-[60px] pb-[80px] sm:px-12'>
|
||||
<Skeleton className='mx-auto h-[48px] w-[320px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='mt-12 space-y-8'>
|
||||
<div className='space-y-[10px]'>
|
||||
<Skeleton className='h-[15px] w-[180px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[140px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[92%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[85%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[260px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[90%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[75%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[380px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[95%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[88%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<div className='mt-12 space-y-[10px]'>
|
||||
<Skeleton className='h-[28px] w-[300px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[93%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[15px] w-[80%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { LegalLayout } from '@/app/(landing)/components'
|
||||
import { ExternalRedirect, LegalLayout } from '@/app/(landing)/components'
|
||||
|
||||
export default function TermsOfService() {
|
||||
useEffect(() => {
|
||||
const termsUrl = getEnv('NEXT_PUBLIC_TERMS_URL')
|
||||
if (termsUrl?.startsWith('http')) {
|
||||
window.location.href = termsUrl
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<LegalLayout title='Terms of Service'>
|
||||
<ExternalRedirect url={getEnv('NEXT_PUBLIC_TERMS_URL') ?? ''} />
|
||||
<section>
|
||||
<p className='mb-4'>Last Updated: October 11, 2025</p>
|
||||
<p>
|
||||
|
||||
110
apps/sim/app/api/help/integration-request/route.ts
Normal file
110
apps/sim/app/api/help/integration-request/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
|
||||
const logger = createLogger('IntegrationRequestAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
refillRate: 5,
|
||||
refillIntervalMs: 60_000,
|
||||
}
|
||||
|
||||
const integrationRequestSchema = z.object({
|
||||
integrationName: z.string().min(1, 'Integration name is required').max(200),
|
||||
email: z.string().email('A valid email is required'),
|
||||
useCase: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const storageKey = `public:integration-request:${ip}`
|
||||
|
||||
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
|
||||
storageKey,
|
||||
PUBLIC_ENDPOINT_RATE_LIMIT
|
||||
)
|
||||
|
||||
if (!allowed) {
|
||||
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const validationResult = integrationRequestSchema.safeParse(body)
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid integration request data`, {
|
||||
errors: validationResult.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: validationResult.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { integrationName, email, useCase } = validationResult.data
|
||||
|
||||
logger.info(`[${requestId}] Processing integration request`, {
|
||||
integrationName,
|
||||
email: `${email.substring(0, 3)}***`,
|
||||
})
|
||||
|
||||
const emailText = `Integration: ${integrationName}
|
||||
From: ${email}
|
||||
Submitted: ${new Date().toISOString()}
|
||||
|
||||
${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'}
|
||||
`
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[INTEGRATION REQUEST] ${integrationName}`,
|
||||
text: emailText,
|
||||
from: getFromEmailAddress(),
|
||||
replyTo: email,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Error sending integration request email`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send request' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Integration request email sent successfully`)
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Integration request submitted successfully' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not configured')) {
|
||||
logger.error(`[${requestId}] Email service configuration error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service is temporarily unavailable. Please try again later.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error processing integration request`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ vi.mock('@/lib/auth/hybrid', () => ({
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
|
||||
}))
|
||||
vi.mock('@/lib/audit/log', () => ({
|
||||
recordAudit: vi.fn(),
|
||||
AuditAction: {},
|
||||
AuditResourceType: {},
|
||||
}))
|
||||
|
||||
import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
|
||||
|
||||
@@ -168,8 +173,16 @@ describe('Connector Documents API Route', () => {
|
||||
})
|
||||
|
||||
it('returns success for restore operation', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockCheckSession.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
userName: 'Test',
|
||||
userEmail: 'test@test.com',
|
||||
})
|
||||
mockCheckWriteAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
|
||||
})
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
|
||||
|
||||
@@ -182,8 +195,16 @@ describe('Connector Documents API Route', () => {
|
||||
})
|
||||
|
||||
it('returns success for exclude operation', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockCheckSession.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
userName: 'Test',
|
||||
userEmail: 'test@test.com',
|
||||
})
|
||||
mockCheckWriteAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
|
||||
})
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
@@ -184,6 +185,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_DOCUMENT_RESTORED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
description: `Restored ${updated.length} excluded document(s) for knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, documentCount: updated.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
@@ -206,6 +220,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_DOCUMENT_EXCLUDED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
description: `Excluded ${updated.length} document(s) from knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, documentCount: updated.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
|
||||
|
||||
@@ -75,6 +75,11 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
vi.mock('@/lib/knowledge/documents/service', () => ({
|
||||
deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/lib/audit/log', () => ({
|
||||
recordAudit: vi.fn(),
|
||||
AuditAction: {},
|
||||
AuditResourceType: {},
|
||||
}))
|
||||
|
||||
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
|
||||
|
||||
@@ -183,8 +188,16 @@ describe('Knowledge Connector By ID API Route', () => {
|
||||
})
|
||||
|
||||
it('returns 200 and updates status', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockCheckSession.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
userName: 'Test',
|
||||
userEmail: 'test@test.com',
|
||||
})
|
||||
mockCheckWriteAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
|
||||
})
|
||||
|
||||
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
|
||||
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
|
||||
@@ -210,8 +223,16 @@ describe('Knowledge Connector By ID API Route', () => {
|
||||
})
|
||||
|
||||
it('returns 200 on successful hard-delete', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockCheckSession.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
userName: 'Test',
|
||||
userEmail: 'test@test.com',
|
||||
})
|
||||
mockCheckWriteAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
|
||||
})
|
||||
mockDbChain.where
|
||||
.mockReturnValueOnce(mockDbChain)
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
|
||||
|
||||
@@ -11,6 +11,7 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
|
||||
@@ -233,6 +234,21 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
.limit(1)
|
||||
|
||||
const { encryptedApiKey: __, ...updatedData } = updated[0]
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_UPDATED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
resourceName: updatedData.connectorType,
|
||||
description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, updatedFields: Object.keys(parsed.data) },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedData })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating connector`, error)
|
||||
@@ -260,7 +276,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const existingConnector = await db
|
||||
.select({ id: knowledgeConnector.id })
|
||||
.select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType })
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
@@ -323,6 +339,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_DELETED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
resourceName: existingConnector[0].connectorType,
|
||||
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting connector`, error)
|
||||
|
||||
@@ -43,6 +43,11 @@ vi.mock('@/lib/core/utils/request', () => ({
|
||||
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
|
||||
dispatchSync: mockDispatchSync,
|
||||
}))
|
||||
vi.mock('@/lib/audit/log', () => ({
|
||||
recordAudit: vi.fn(),
|
||||
AuditAction: {},
|
||||
AuditResourceType: {},
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route'
|
||||
|
||||
@@ -92,8 +97,16 @@ describe('Connector Manual Sync API Route', () => {
|
||||
})
|
||||
|
||||
it('dispatches sync on valid request', async () => {
|
||||
mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockCheckSession.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
userName: 'Test',
|
||||
userEmail: 'test@test.com',
|
||||
})
|
||||
mockCheckWriteAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
|
||||
})
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
|
||||
|
||||
const req = createMockRequest('POST')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { knowledgeConnector } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
@@ -54,6 +55,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_SYNCED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
resourceName: connectorRows[0].connectorType,
|
||||
description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId },
|
||||
request,
|
||||
})
|
||||
|
||||
dispatchSync(connectorId, { requestId }).catch((error) => {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, isNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { encryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
@@ -226,6 +227,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.CONNECTOR_CREATED,
|
||||
resourceType: AuditResourceType.CONNECTOR,
|
||||
resourceId: connectorId,
|
||||
resourceName: connectorType,
|
||||
description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, connectorType, syncIntervalMinutes },
|
||||
request,
|
||||
})
|
||||
|
||||
dispatchSync(connectorId, { requestId }).catch((error) => {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { knowledgeBase } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { restoreKnowledgeBase } from '@/lib/knowledge/service'
|
||||
@@ -23,6 +24,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const [kb] = await db
|
||||
.select({
|
||||
id: knowledgeBase.id,
|
||||
name: knowledgeBase.name,
|
||||
workspaceId: knowledgeBase.workspaceId,
|
||||
userId: knowledgeBase.userId,
|
||||
})
|
||||
@@ -47,6 +49,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Restored knowledge base ${id}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: kb.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.KNOWLEDGE_BASE_RESTORED,
|
||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||
resourceId: id,
|
||||
resourceName: kb.name,
|
||||
description: `Restored knowledge base "${kb.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error)
|
||||
|
||||
@@ -27,6 +27,34 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
const STALE_SYNC_TTL_MS = 120 * 60 * 1000
|
||||
const staleCutoff = new Date(now.getTime() - STALE_SYNC_TTL_MS)
|
||||
|
||||
const recoveredConnectors = await db
|
||||
.update(knowledgeConnector)
|
||||
.set({
|
||||
status: 'error',
|
||||
lastSyncError: 'Sync timed out (stale lock recovered)',
|
||||
nextSyncAt: new Date(now.getTime() + 10 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeConnector.status, 'syncing'),
|
||||
lte(knowledgeConnector.updatedAt, staleCutoff),
|
||||
isNull(knowledgeConnector.archivedAt),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: knowledgeConnector.id })
|
||||
|
||||
if (recoveredConnectors.length > 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Recovered ${recoveredConnectors.length} stale syncing connectors`,
|
||||
{ ids: recoveredConnectors.map((c) => c.id) }
|
||||
)
|
||||
}
|
||||
|
||||
const dueConnectors = await db
|
||||
.select({
|
||||
id: knowledgeConnector.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getTableById, restoreTable } from '@/lib/table'
|
||||
@@ -34,6 +35,19 @@ export async function POST(
|
||||
|
||||
logger.info(`[${requestId}] Restored table ${tableId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: table.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.TABLE_RESTORED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: table.name,
|
||||
description: `Restored table "${table.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring table ${tableId}`, error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { restoreWorkflow } from '@/lib/workflows/lifecycle'
|
||||
@@ -44,6 +45,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Restored workflow ${workflowId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: workflowData.workspaceId,
|
||||
actorId: auth.userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.WORKFLOW_RESTORED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: workflowId,
|
||||
resourceName: workflowData.name,
|
||||
description: `Restored workflow "${workflowData.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
@@ -29,6 +30,19 @@ export async function POST(
|
||||
|
||||
logger.info(`[${requestId}] Restored workspace file ${fileId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.FILE_RESTORED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileId,
|
||||
resourceName: fileId,
|
||||
description: `Restored workspace file ${fileId}`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function ChangelogLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function ChangelogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
return (
|
||||
<div
|
||||
className={`${martianMono.variable} relative min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
{children}
|
||||
<Footer hideCTA />
|
||||
|
||||
38
apps/sim/app/changelog/loading.tsx
Normal file
38
apps/sim/app/changelog/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function ChangelogLoading() {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='relative grid md:grid-cols-2'>
|
||||
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
<div className='relative mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<Skeleton className='mt-6 h-[48px] w-[200px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-4 h-[14px] w-[300px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[260px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='mt-6 h-[1px] w-full bg-[#2A2A2A]' />
|
||||
<div className='mt-6 flex flex-wrap items-center gap-3'>
|
||||
<Skeleton className='h-[32px] w-[130px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[32px] w-[120px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[5px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-16 sm:px-10 md:px-12 md:py-24'>
|
||||
<div className='max-w-2xl space-y-[32px]'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className='space-y-[12px]'>
|
||||
<Skeleton className='h-[20px] w-[160px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[100px] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
<Skeleton className='h-[14px] w-[75%] rounded-[4px] bg-[#2A2A2A]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
apps/sim/app/chat/[identifier]/loading.tsx
Normal file
39
apps/sim/app/chat/[identifier]/loading.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function ChatLoading() {
|
||||
return (
|
||||
<div className='fixed inset-0 z-[100] flex flex-col bg-white text-foreground'>
|
||||
<div className='border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-3xl items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[28px] w-[28px] rounded-[6px]' />
|
||||
<Skeleton className='h-[18px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-h-0 flex-1 items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
|
||||
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/credential-account/[token]/loading.tsx
Normal file
19
apps/sim/app/credential-account/[token]/loading.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function CredentialAccountLoading() {
|
||||
return (
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='mt-[16px] h-[24px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[44px] w-[200px] rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
29
apps/sim/app/form/[identifier]/loading.tsx
Normal file
29
apps/sim/app/form/[identifier]/loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function FormLoading() {
|
||||
return (
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
13
apps/sim/app/invite/[id]/loading.tsx
Normal file
13
apps/sim/app/invite/[id]/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function InviteLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='mt-[16px] h-[24px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[44px] w-[200px] rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
|
||||
## Core Pages
|
||||
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
|
||||
export default function NotFound() {
|
||||
export default async function NotFound() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
|
||||
<header className='shrink-0 bg-[#1C1C1C]'>
|
||||
<Navbar />
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
|
||||
42
apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx
Normal file
42
apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function ResumeLoading() {
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<div className='border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-[1200px] items-center justify-between'>
|
||||
<Skeleton className='h-[24px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[28px] w-[100px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[1200px] px-6 py-8'>
|
||||
<div className='grid grid-cols-[280px_1fr] gap-6'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[20px] w-[120px] rounded-[4px]' />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[48px] w-full rounded-[8px]' />
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-[8px] border p-6'>
|
||||
<Skeleton className='h-[24px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[16px] w-[320px] rounded-[4px]' />
|
||||
<div className='mt-[24px] space-y-[16px]'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[100px] rounded-[4px]' />
|
||||
<Skeleton className='h-[80px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[24px] flex gap-[12px]'>
|
||||
<Skeleton className='h-[40px] w-[120px] rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-[120px] rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
url: `${baseUrl}/blog/tags`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/templates`,
|
||||
lastModified: now,
|
||||
},
|
||||
// {
|
||||
// url: `${baseUrl}/templates`,
|
||||
// lastModified: now,
|
||||
// },
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: now,
|
||||
|
||||
@@ -1,44 +1,20 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
// import { db } from '@sim/db'
|
||||
// import { permissions, workspace } from '@sim/db/schema'
|
||||
// import { and, desc, eq } from 'drizzle-orm'
|
||||
// import { redirect } from 'next/navigation'
|
||||
// import { getSession } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
// export const dynamic = 'force-dynamic'
|
||||
// export const revalidate = 0
|
||||
|
||||
interface TemplateLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Template detail layout (public scope).
|
||||
* - If user is authenticated, redirect to workspace-scoped template detail.
|
||||
* - Otherwise render the public template detail children.
|
||||
* Template detail layout (public scope) — currently disabled.
|
||||
* Previously redirected authenticated users to the workspace-scoped template detail.
|
||||
*/
|
||||
export default async function TemplateDetailLayout({ children, params }: TemplateLayoutProps) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default function TemplateDetailLayout({ children }: TemplateLayoutProps) {
|
||||
return children
|
||||
}
|
||||
|
||||
55
apps/sim/app/templates/[id]/loading.tsx
Normal file
55
apps/sim/app/templates/[id]/loading.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function TemplateDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
<div className='border-b px-6 py-3'>
|
||||
<div className='mx-auto flex max-w-[1200px] items-center justify-between'>
|
||||
<Skeleton className='h-[24px] w-[80px] rounded-[4px]' />
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto w-full max-w-[1200px] px-6 pt-[24px] pb-[24px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[72px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<Skeleton className='h-[27px] w-[250px] rounded-[4px]' />
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='mt-[4px] h-[16px] w-[360px] rounded-[4px]' />
|
||||
<div className='mt-[16px] flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[16px] w-[48px] rounded-[4px]' />
|
||||
<Skeleton className='h-[16px] w-[48px] rounded-[4px]' />
|
||||
<Skeleton className='h-[16px] w-[1px] rounded-[1px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-full' />
|
||||
<Skeleton className='h-[16px] w-[80px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[450px] w-full rounded-[8px]' />
|
||||
<Skeleton className='mt-[32px] h-[20px] w-[180px] rounded-[4px]' />
|
||||
<div className='mt-[12px] space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-full rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[85%] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[32px] h-[20px] w-[160px] rounded-[4px]' />
|
||||
<div className='mt-[12px] flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-full' />
|
||||
<div className='space-y-[6px]'>
|
||||
<Skeleton className='h-[16px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +1,93 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templates } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
const logger = createLogger('TemplateMetadata')
|
||||
// import { db } from '@sim/db'
|
||||
// import { templateCreators, templates } from '@sim/db/schema'
|
||||
// import { createLogger } from '@sim/logger'
|
||||
// import { eq } from 'drizzle-orm'
|
||||
// import type { Metadata } from 'next'
|
||||
// import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
// import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
// const logger = createLogger('TemplateMetadata')
|
||||
|
||||
// /**
|
||||
// * Generate dynamic metadata for template pages.
|
||||
// * This provides OpenGraph images for social media sharing.
|
||||
// */
|
||||
// export async function generateMetadata({
|
||||
// params,
|
||||
// }: {
|
||||
// params: Promise<{ id: string }>
|
||||
// }): Promise<Metadata> {
|
||||
// const { id } = await params
|
||||
//
|
||||
// try {
|
||||
// const result = await db
|
||||
// .select({
|
||||
// template: templates,
|
||||
// creator: templateCreators,
|
||||
// })
|
||||
// .from(templates)
|
||||
// .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
// .where(eq(templates.id, id))
|
||||
// .limit(1)
|
||||
//
|
||||
// if (result.length === 0) {
|
||||
// return {
|
||||
// title: 'Template Not Found',
|
||||
// description: 'The requested template could not be found.',
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const { template, creator } = result[0]
|
||||
// const baseUrl = getBaseUrl()
|
||||
//
|
||||
// const details = template.details as { tagline?: string; about?: string } | null
|
||||
// const description = details?.tagline || 'AI workflow template on Sim'
|
||||
//
|
||||
// const hasOgImage = !!template.ogImageUrl
|
||||
// const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
//
|
||||
// return {
|
||||
// title: template.name,
|
||||
// description,
|
||||
// openGraph: {
|
||||
// title: template.name,
|
||||
// description,
|
||||
// type: 'website',
|
||||
// url: `${baseUrl}/templates/${id}`,
|
||||
// siteName: 'Sim',
|
||||
// images: [
|
||||
// {
|
||||
// url: ogImageUrl,
|
||||
// width: hasOgImage ? 1200 : 512,
|
||||
// height: hasOgImage ? 630 : 512,
|
||||
// alt: `${template.name} - Workflow Preview`,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// twitter: {
|
||||
// card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
// title: template.name,
|
||||
// description,
|
||||
// images: [ogImageUrl],
|
||||
// creator: creator?.details
|
||||
// ? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
// : undefined,
|
||||
// },
|
||||
// }
|
||||
// } catch (error) {
|
||||
// logger.error('Failed to generate template metadata:', error)
|
||||
// return {
|
||||
// title: 'Template',
|
||||
// description: 'AI workflow template on Sim',
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata for template pages.
|
||||
* This provides OpenGraph images for social media sharing.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
title: 'Template Not Found',
|
||||
description: 'The requested template could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const { template, creator } = result[0]
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const details = template.details as { tagline?: string; about?: string } | null
|
||||
const description = details?.tagline || 'AI workflow template on Sim'
|
||||
|
||||
const hasOgImage = !!template.ogImageUrl
|
||||
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
|
||||
return {
|
||||
title: template.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title: template.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${baseUrl}/templates/${id}`,
|
||||
siteName: 'Sim',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: hasOgImage ? 1200 : 512,
|
||||
height: hasOgImage ? 630 : 512,
|
||||
alt: `${template.name} - Workflow Preview`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
title: template.name,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
creator: creator?.details
|
||||
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate template metadata:', error)
|
||||
return {
|
||||
title: 'Template',
|
||||
description: 'AI workflow template on Sim',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public template detail page for unauthenticated users.
|
||||
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
|
||||
* Public template detail page — currently disabled, returns 404.
|
||||
*/
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
notFound()
|
||||
}
|
||||
|
||||
36
apps/sim/app/templates/loading.tsx
Normal file
36
apps/sim/app/templates/loading.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_CARD_COUNT = 8
|
||||
|
||||
export default function TemplatesLoading() {
|
||||
return (
|
||||
<div className='min-h-screen bg-white'>
|
||||
<div className='border-b px-6 py-3'>
|
||||
<div className='mx-auto flex max-w-[1200px] items-center justify-between'>
|
||||
<Skeleton className='h-[24px] w-[80px] rounded-[4px]' />
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[1200px] px-6 py-8'>
|
||||
<Skeleton className='h-[40px] w-[400px] rounded-[8px]' />
|
||||
<div className='mt-[16px] flex gap-[8px]'>
|
||||
<Skeleton className='h-[32px] w-[64px] rounded-[6px]' />
|
||||
</div>
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
|
||||
<div key={i} className='h-[268px] w-full rounded-[8px] bg-[var(--surface-3)] p-[8px]'>
|
||||
<Skeleton className='h-[180px] w-full rounded-[6px]' />
|
||||
<div className='mt-[10px] px-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[6px] h-[12px] w-[180px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +1,79 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import Templates from '@/app/templates/templates'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
description:
|
||||
'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
|
||||
}
|
||||
// import { db } from '@sim/db'
|
||||
// import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
|
||||
// import { and, desc, eq } from 'drizzle-orm'
|
||||
// import type { Metadata } from 'next'
|
||||
// import { redirect } from 'next/navigation'
|
||||
// import { getSession } from '@/lib/auth'
|
||||
// import type { Template } from '@/app/templates/templates'
|
||||
// import Templates from '@/app/templates/templates'
|
||||
|
||||
// export const metadata: Metadata = {
|
||||
// title: 'Templates',
|
||||
// description:
|
||||
// 'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
|
||||
// }
|
||||
|
||||
/**
|
||||
* Public templates list page.
|
||||
* Redirects authenticated users to their workspace-scoped templates page.
|
||||
* Allows unauthenticated users to view templates for SEO and discovery.
|
||||
* Currently disabled — returns 404.
|
||||
*/
|
||||
export default async function TemplatesPage() {
|
||||
const session = await getSession()
|
||||
export default function TemplatesPage() {
|
||||
notFound()
|
||||
|
||||
// Authenticated users: redirect to workspace-scoped templates
|
||||
if (session?.user?.id) {
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates`)
|
||||
}
|
||||
}
|
||||
|
||||
// Unauthenticated users: show public templates
|
||||
const templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={templatesData as unknown as Template[]}
|
||||
currentUserId={null}
|
||||
isSuperUser={false}
|
||||
/>
|
||||
)
|
||||
// Redirects authenticated users to their workspace-scoped templates page.
|
||||
// Allows unauthenticated users to view templates for SEO and discovery.
|
||||
//
|
||||
// const session = await getSession()
|
||||
//
|
||||
// // Authenticated users: redirect to workspace-scoped templates
|
||||
// if (session?.user?.id) {
|
||||
// const userWorkspaces = await db
|
||||
// .select({
|
||||
// workspace: workspace,
|
||||
// })
|
||||
// .from(permissions)
|
||||
// .innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
// .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
// .orderBy(desc(workspace.createdAt))
|
||||
// .limit(1)
|
||||
//
|
||||
// if (userWorkspaces.length > 0) {
|
||||
// const firstWorkspace = userWorkspaces[0].workspace
|
||||
// redirect(`/workspace/${firstWorkspace.id}/templates`)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Unauthenticated users: show public templates
|
||||
// const templatesData = await db
|
||||
// .select({
|
||||
// id: templates.id,
|
||||
// workflowId: templates.workflowId,
|
||||
// name: templates.name,
|
||||
// details: templates.details,
|
||||
// creatorId: templates.creatorId,
|
||||
// creator: templateCreators,
|
||||
// views: templates.views,
|
||||
// stars: templates.stars,
|
||||
// status: templates.status,
|
||||
// tags: templates.tags,
|
||||
// requiredCredentials: templates.requiredCredentials,
|
||||
// state: templates.state,
|
||||
// createdAt: templates.createdAt,
|
||||
// updatedAt: templates.updatedAt,
|
||||
// })
|
||||
// .from(templates)
|
||||
// .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
// .where(eq(templates.status, 'approved'))
|
||||
// .orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
// .then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
//
|
||||
// return (
|
||||
// <Templates
|
||||
// initialTemplates={templatesData as unknown as Template[]}
|
||||
// currentUserId={null}
|
||||
// isSuperUser={false}
|
||||
// />
|
||||
// )
|
||||
}
|
||||
|
||||
13
apps/sim/app/unsubscribe/loading.tsx
Normal file
13
apps/sim/app/unsubscribe/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function UnsubscribeLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-[12px]' />
|
||||
<Skeleton className='mt-[16px] h-[24px] w-[180px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[8px] h-[14px] w-[300px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[4px] h-[14px] w-[260px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[24px] h-[44px] w-[200px] rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function FileViewLoading() {
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/workspace/[workspaceId]/files/loading.tsx
Normal file
62
apps/sim/app/workspace/[workspaceId]/files/loading.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
export default function FilesLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[32px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[72px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[64px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
LandingPromptStorage,
|
||||
LandingTemplateStorage,
|
||||
type LandingWorkflowSeed,
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
@@ -118,12 +117,12 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const templateId = LandingTemplateStorage.consume()
|
||||
if (templateId) {
|
||||
logger.info('Retrieved landing page template, redirecting to template detail')
|
||||
router.replace(`/workspace/${workspaceId}/templates/${templateId}?use=true`)
|
||||
return
|
||||
}
|
||||
// const templateId = LandingTemplateStorage.consume()
|
||||
// if (templateId) {
|
||||
// logger.info('Retrieved landing page template, redirecting to template detail')
|
||||
// router.replace(`/workspace/${workspaceId}/templates/${templateId}?use=true`)
|
||||
// return
|
||||
// }
|
||||
|
||||
const prompt = LandingPromptStorage.consume()
|
||||
if (prompt) {
|
||||
|
||||
22
apps/sim/app/workspace/[workspaceId]/home/loading.tsx
Normal file
22
apps/sim/app/workspace/[workspaceId]/home/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_LINE_COUNT = 4
|
||||
|
||||
export default function HomeLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
|
||||
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
|
||||
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 4
|
||||
|
||||
export default function DocumentLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[16px] py-[8.5px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[100px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '200px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 7
|
||||
|
||||
export default function KnowledgeBaseLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[16px] py-[8.5px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[112px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[140px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx
Normal file
63
apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
Normal file
71
apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
export default function LogsLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[32px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[64px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[96px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[28px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[72px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
<Skeleton className='h-[28px] w-[56px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 4
|
||||
|
||||
export default function ScheduledTasksLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[104px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[136px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{
|
||||
width: colIndex === 0 ? '128px' : colIndex === 1 ? '160px' : '80px',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton className='mb-[28px] h-[28px] w-[140px] rounded-[4px]' />
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[20px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,13 +57,13 @@ const Credentials = dynamic(
|
||||
),
|
||||
{ loading: () => <CredentialsSkeleton /> }
|
||||
)
|
||||
const TemplateProfile = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile'
|
||||
).then((m) => m.TemplateProfile),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
// const TemplateProfile = dynamic(
|
||||
// () =>
|
||||
// import(
|
||||
// '@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile'
|
||||
// ).then((m) => m.TemplateProfile),
|
||||
// { loading: () => <SettingsSectionSkeleton /> }
|
||||
// )
|
||||
const CredentialSets = dynamic(
|
||||
() =>
|
||||
import(
|
||||
@@ -177,7 +177,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
|
||||
{effectiveSection === 'general' && <General />}
|
||||
{effectiveSection === 'integrations' && <Integrations />}
|
||||
{effectiveSection === 'secrets' && <Credentials />}
|
||||
{effectiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{/* {effectiveSection === 'template-profile' && <TemplateProfile />} */}
|
||||
{effectiveSection === 'credential-sets' && <CredentialSets />}
|
||||
{effectiveSection === 'access-control' && <AccessControl />}
|
||||
{effectiveSection === 'apikeys' && <ApiKeys />}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ShieldCheck,
|
||||
TerminalWindow,
|
||||
TrashOutline,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from '@/components/emcn'
|
||||
@@ -84,7 +83,7 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||
|
||||
export const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'general', label: 'General', icon: Settings, section: 'account' },
|
||||
{ id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
|
||||
// { id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
|
||||
{
|
||||
id: 'access-control',
|
||||
label: 'Access Control',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 8
|
||||
const COLUMN_COUNT = 5
|
||||
|
||||
export default function TableDetailLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[16px] py-[8.5px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[44px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[72px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: `${80 + (colIndex % 3) * 40}px` }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
apps/sim/app/workspace/[workspaceId]/tables/loading.tsx
Normal file
61
apps/sim/app/workspace/[workspaceId]/tables/loading.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_ROW_COUNT = 5
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
export default function TablesLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[44px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
<Skeleton className='ml-[10px] h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full'>
|
||||
<thead>
|
||||
<tr className='border-[var(--border)] border-b'>
|
||||
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</th>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||
</td>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||
<Skeleton
|
||||
className='h-[14px] rounded-[4px]'
|
||||
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export default function TemplateDetailLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[24px] pb-[24px] dark:bg-[var(--bg)]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[72px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[8px] rounded-[2px]' />
|
||||
<Skeleton className='h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<Skeleton className='h-[27px] w-[250px] rounded-[4px]' />
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='mt-[4px] h-[16px] w-[360px] rounded-[4px]' />
|
||||
<div className='mt-[16px] flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[16px] w-[48px] rounded-[4px]' />
|
||||
<Skeleton className='h-[16px] w-[48px] rounded-[4px]' />
|
||||
<Skeleton className='h-[16px] w-[1px] rounded-[1px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-full' />
|
||||
<Skeleton className='h-[16px] w-[80px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[24px] h-[450px] w-full rounded-[8px]' />
|
||||
<Skeleton className='mt-[32px] h-[20px] w-[180px] rounded-[4px]' />
|
||||
<div className='mt-[12px] space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-full rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[85%] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[70%] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[32px] h-[20px] w-[160px] rounded-[4px]' />
|
||||
<div className='mt-[12px] flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[48px] w-[48px] rounded-full' />
|
||||
<div className='space-y-[6px]'>
|
||||
<Skeleton className='h-[16px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[200px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +1,103 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templates } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
const logger = createLogger('WorkspaceTemplateMetadata')
|
||||
// import { db } from '@sim/db'
|
||||
// import { templateCreators, templates } from '@sim/db/schema'
|
||||
// import { createLogger } from '@sim/logger'
|
||||
// import { eq } from 'drizzle-orm'
|
||||
// import type { Metadata } from 'next'
|
||||
// import { redirect } from 'next/navigation'
|
||||
// import { getSession } from '@/lib/auth'
|
||||
// import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
// import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
// import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
// const logger = createLogger('WorkspaceTemplateMetadata')
|
||||
|
||||
// interface TemplatePageProps {
|
||||
// params: Promise<{
|
||||
// workspaceId: string
|
||||
// id: string
|
||||
// }>
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Generate dynamic metadata for workspace template pages.
|
||||
// * This provides OpenGraph images for social media sharing.
|
||||
// */
|
||||
// export async function generateMetadata({
|
||||
// params,
|
||||
// }: {
|
||||
// params: Promise<{ workspaceId: string; id: string }>
|
||||
// }): Promise<Metadata> {
|
||||
// const { workspaceId, id } = await params
|
||||
//
|
||||
// try {
|
||||
// const result = await db
|
||||
// .select({
|
||||
// template: templates,
|
||||
// creator: templateCreators,
|
||||
// })
|
||||
// .from(templates)
|
||||
// .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
// .where(eq(templates.id, id))
|
||||
// .limit(1)
|
||||
//
|
||||
// if (result.length === 0) {
|
||||
// return {
|
||||
// title: 'Template Not Found',
|
||||
// description: 'The requested template could not be found.',
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const { template, creator } = result[0]
|
||||
// const baseUrl = getBaseUrl()
|
||||
//
|
||||
// const details = template.details as { tagline?: string; about?: string } | null
|
||||
// const description = details?.tagline || 'AI workflow template on Sim'
|
||||
//
|
||||
// const hasOgImage = !!template.ogImageUrl
|
||||
// const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
//
|
||||
// return {
|
||||
// title: template.name,
|
||||
// description,
|
||||
// openGraph: {
|
||||
// title: template.name,
|
||||
// description,
|
||||
// type: 'website',
|
||||
// url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
|
||||
// siteName: 'Sim',
|
||||
// images: [
|
||||
// {
|
||||
// url: ogImageUrl,
|
||||
// width: hasOgImage ? 1200 : 512,
|
||||
// height: hasOgImage ? 630 : 512,
|
||||
// alt: `${template.name} - Workflow Preview`,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// twitter: {
|
||||
// card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
// title: template.name,
|
||||
// description,
|
||||
// images: [ogImageUrl],
|
||||
// creator: creator?.details
|
||||
// ? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
// : undefined,
|
||||
// },
|
||||
// }
|
||||
// } catch (error) {
|
||||
// logger.error('Failed to generate workspace template metadata:', error)
|
||||
// return {
|
||||
// title: 'Template',
|
||||
// description: 'AI workflow template on Sim',
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata for workspace template pages.
|
||||
* This provides OpenGraph images for social media sharing.
|
||||
* Workspace-scoped template detail page — currently disabled, returns 404.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string; id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { workspaceId, id } = await params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
title: 'Template Not Found',
|
||||
description: 'The requested template could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const { template, creator } = result[0]
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const details = template.details as { tagline?: string; about?: string } | null
|
||||
const description = details?.tagline || 'AI workflow template on Sim'
|
||||
|
||||
const hasOgImage = !!template.ogImageUrl
|
||||
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
|
||||
return {
|
||||
title: template.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title: template.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
|
||||
siteName: 'Sim',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: hasOgImage ? 1200 : 512,
|
||||
height: hasOgImage ? 630 : 512,
|
||||
alt: `${template.name} - Workflow Preview`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
title: template.name,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
creator: creator?.details
|
||||
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate workspace template metadata:', error)
|
||||
return {
|
||||
title: 'Template',
|
||||
description: 'AI workflow template on Sim',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped template detail page.
|
||||
* Requires authentication and workspace membership to access.
|
||||
* Uses the shared TemplateDetails component with workspace context.
|
||||
*/
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect(`/templates/${id}`)
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <TemplateDetails isWorkspaceContext={true} />
|
||||
export default function TemplatePage() {
|
||||
notFound()
|
||||
}
|
||||
|
||||
38
apps/sim/app/workspace/[workspaceId]/templates/loading.tsx
Normal file
38
apps/sim/app/workspace/[workspaceId]/templates/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_CARD_COUNT = 8
|
||||
|
||||
export default function TemplatesLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto bg-[var(--bg)] px-[24px] pt-[28px] pb-[24px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-[26px] w-[26px] rounded-[6px]' />
|
||||
<Skeleton className='h-[22px] w-[80px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-[6px] h-[14px] w-[280px] rounded-[4px]' />
|
||||
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<Skeleton className='h-[32px] w-[400px] rounded-[8px]' />
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[32px] w-[64px] rounded-[6px]' />
|
||||
<Skeleton className='h-[32px] w-[100px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
|
||||
<div key={i} className='h-[268px] w-full rounded-[8px] bg-[var(--surface-3)] p-[8px]'>
|
||||
<Skeleton className='h-[180px] w-full rounded-[6px]' />
|
||||
<div className='mt-[10px] px-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[6px] h-[12px] w-[180px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,205 +1,29 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
}
|
||||
// import { db } from '@sim/db'
|
||||
// import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
// import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
// import type { Metadata } from 'next'
|
||||
// import { redirect } from 'next/navigation'
|
||||
// import { getSession } from '@/lib/auth'
|
||||
// import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
// import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
// import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
// import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
// export const metadata: Metadata = {
|
||||
// title: 'Templates',
|
||||
// }
|
||||
|
||||
// interface TemplatesPageProps {
|
||||
// params: Promise<{
|
||||
// workspaceId: string
|
||||
// }>
|
||||
// }
|
||||
|
||||
/**
|
||||
* Workspace-scoped Templates page.
|
||||
* Requires authentication and workspace membership to access.
|
||||
* Workspace-scoped Templates page — currently disabled, returns 404.
|
||||
*/
|
||||
export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Redirect unauthenticated users to public templates page
|
||||
if (!session?.user?.id) {
|
||||
redirect('/templates')
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideTemplates) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
// Determine effective super user (admin role AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.role === 'admin'
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? false
|
||||
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
|
||||
// Load templates from database
|
||||
let rows:
|
||||
| Array<{
|
||||
id: string
|
||||
workflowId: string | null
|
||||
name: string
|
||||
details?: unknown
|
||||
creatorId: string | null
|
||||
creator: {
|
||||
id: string
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
name: string
|
||||
profileImageUrl?: string | null
|
||||
details?: unknown
|
||||
verified: boolean
|
||||
} | null
|
||||
views: number
|
||||
stars: number
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
tags: string[]
|
||||
requiredCredentials: unknown
|
||||
state: unknown
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
isStarred?: boolean
|
||||
}>
|
||||
| undefined
|
||||
|
||||
if (session?.user?.id) {
|
||||
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
|
||||
rows = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
} else {
|
||||
rows = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((r) => r.map((row) => ({ ...row, isStarred: false })))
|
||||
}
|
||||
|
||||
const initialTemplates: WorkspaceTemplate[] =
|
||||
rows?.map((row) => {
|
||||
const authorType = (row.creator?.referenceType as 'user' | 'organization') ?? 'user'
|
||||
const organizationId =
|
||||
row.creator?.referenceType === 'organization' ? row.creator.referenceId : null
|
||||
const userId =
|
||||
row.creator?.referenceType === 'user' ? row.creator.referenceId : '' /* no owner context */
|
||||
|
||||
return {
|
||||
// New structure fields
|
||||
id: row.id,
|
||||
workflowId: row.workflowId,
|
||||
name: row.name,
|
||||
details: row.details as { tagline?: string; about?: string } | null,
|
||||
creatorId: row.creatorId,
|
||||
creator: row.creator
|
||||
? {
|
||||
id: row.creator.id,
|
||||
name: row.creator.name,
|
||||
profileImageUrl: row.creator.profileImageUrl,
|
||||
details: row.creator.details as {
|
||||
about?: string
|
||||
xUrl?: string
|
||||
linkedinUrl?: string
|
||||
websiteUrl?: string
|
||||
contactEmail?: string
|
||||
} | null,
|
||||
referenceType: row.creator.referenceType,
|
||||
referenceId: row.creator.referenceId,
|
||||
verified: row.creator.verified,
|
||||
}
|
||||
: null,
|
||||
views: row.views,
|
||||
stars: row.stars,
|
||||
status: row.status,
|
||||
tags: row.tags,
|
||||
requiredCredentials: row.requiredCredentials,
|
||||
state: row.state as WorkspaceTemplate['state'],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isStarred: row.isStarred ?? false,
|
||||
isSuperUser: effectiveSuperUser,
|
||||
// Legacy fields for backward compatibility
|
||||
userId,
|
||||
description: (row.details as any)?.tagline ?? null,
|
||||
author: row.creator?.name ?? 'Unknown',
|
||||
authorType,
|
||||
organizationId,
|
||||
color: '#3972F6', // default color for workspace cards
|
||||
icon: 'Workflow', // default icon for workspace cards
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={initialTemplates}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
isSuperUser={effectiveSuperUser}
|
||||
/>
|
||||
)
|
||||
export default function TemplatesPage() {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'goto-templates'
|
||||
// | 'goto-templates'
|
||||
| 'goto-logs'
|
||||
| 'open-search'
|
||||
| 'run-workflow'
|
||||
@@ -52,11 +52,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
shortcut: 'Mod+Shift+A',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'goto-templates': {
|
||||
id: 'goto-templates',
|
||||
shortcut: 'Mod+Y',
|
||||
allowInEditable: true,
|
||||
},
|
||||
// 'goto-templates': {
|
||||
// id: 'goto-templates',
|
||||
// shortcut: 'Mod+Y',
|
||||
// allowInEditable: true,
|
||||
// },
|
||||
'goto-logs': {
|
||||
id: 'goto-logs',
|
||||
shortcut: 'Mod+L',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Library } from '@/components/emcn'
|
||||
@@ -29,11 +29,11 @@ interface CommandItem {
|
||||
* Available commands list
|
||||
*/
|
||||
const commands: CommandItem[] = [
|
||||
{
|
||||
label: 'Templates',
|
||||
icon: Layout,
|
||||
shortcut: 'Y',
|
||||
},
|
||||
// {
|
||||
// label: 'Templates',
|
||||
// icon: Layout,
|
||||
// shortcut: 'Y',
|
||||
// },
|
||||
{
|
||||
label: 'New Agent',
|
||||
icon: AgentIcon,
|
||||
@@ -78,14 +78,14 @@ export function CommandList() {
|
||||
(label: string) => {
|
||||
try {
|
||||
switch (label) {
|
||||
case 'Templates': {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates from command list')
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
return
|
||||
}
|
||||
// case 'Templates': {
|
||||
// if (!workspaceId) {
|
||||
// logger.warn('No workspace ID found, cannot navigate to templates from command list')
|
||||
// return
|
||||
// }
|
||||
// router.push(`/workspace/${workspaceId}/templates`)
|
||||
// return
|
||||
// }
|
||||
case 'New Agent': {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: 'agent', enableTriggerMode: false },
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
useDeployWorkflow,
|
||||
useUndeployWorkflow,
|
||||
} from '@/hooks/queries/deployments'
|
||||
import { useTemplateByWorkflow } from '@/hooks/queries/templates'
|
||||
// import { useTemplateByWorkflow } from '@/hooks/queries/templates'
|
||||
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -73,7 +73,7 @@ interface WorkflowDeploymentInfoUI {
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
|
||||
type TabView = 'general' | 'api' | 'chat' | /* 'template' | */ 'mcp' | 'form' | 'a2a'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -102,8 +102,8 @@ export function DeployModal({
|
||||
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
|
||||
|
||||
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
|
||||
const [templateFormValid, setTemplateFormValid] = useState(false)
|
||||
const [templateSubmitting, setTemplateSubmitting] = useState(false)
|
||||
// const [templateFormValid, setTemplateFormValid] = useState(false)
|
||||
// const [templateSubmitting, setTemplateSubmitting] = useState(false)
|
||||
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
|
||||
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
|
||||
const [a2aSubmitting, setA2aSubmitting] = useState(false)
|
||||
@@ -159,17 +159,17 @@ export function DeployModal({
|
||||
const hasA2aAgent = !!existingA2aAgent
|
||||
const isA2aPublished = existingA2aAgent?.isPublished ?? false
|
||||
|
||||
const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
|
||||
enabled: !!workflowId,
|
||||
})
|
||||
const hasExistingTemplate = !!existingTemplate
|
||||
const templateStatus = existingTemplate
|
||||
? {
|
||||
status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
|
||||
views: existingTemplate.views,
|
||||
stars: existingTemplate.stars,
|
||||
}
|
||||
: null
|
||||
// const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
|
||||
// enabled: !!workflowId,
|
||||
// })
|
||||
// const hasExistingTemplate = !!existingTemplate
|
||||
// const templateStatus = existingTemplate
|
||||
// ? {
|
||||
// status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
|
||||
// views: existingTemplate.views,
|
||||
// stars: existingTemplate.stars,
|
||||
// }
|
||||
// : null
|
||||
|
||||
const deployMutation = useDeployWorkflow()
|
||||
const undeployMutation = useUndeployWorkflow()
|
||||
@@ -406,10 +406,10 @@ export function DeployModal({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTemplateFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('template-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
// const handleTemplateFormSubmit = useCallback(() => {
|
||||
// const form = document.getElementById('template-deploy-form') as HTMLFormElement
|
||||
// form?.requestSubmit()
|
||||
// }, [])
|
||||
|
||||
const handleMcpToolFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('mcp-deploy-form') as HTMLFormElement
|
||||
@@ -453,11 +453,11 @@ export function DeployModal({
|
||||
setShowA2aDeleteConfirm(false)
|
||||
}, [])
|
||||
|
||||
const handleTemplateDelete = useCallback(() => {
|
||||
const form = document.getElementById('template-deploy-form')
|
||||
const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
// const handleTemplateDelete = useCallback(() => {
|
||||
// const form = document.getElementById('template-deploy-form')
|
||||
// const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
|
||||
// deleteTrigger?.click()
|
||||
// }, [])
|
||||
|
||||
const isSubmitting = deployMutation.isPending
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
@@ -938,28 +938,28 @@ function StatusBadge({ isWarning }: StatusBadgeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface TemplateStatusBadgeProps {
|
||||
status: 'pending' | 'approved' | 'rejected' | null
|
||||
views?: number
|
||||
stars?: number
|
||||
}
|
||||
// interface TemplateStatusBadgeProps {
|
||||
// status: 'pending' | 'approved' | 'rejected' | null
|
||||
// views?: number
|
||||
// stars?: number
|
||||
// }
|
||||
|
||||
function TemplateStatusBadge({ status, views, stars }: TemplateStatusBadgeProps) {
|
||||
const isPending = status === 'pending'
|
||||
const label = isPending ? 'Under review' : 'Live'
|
||||
// function TemplateStatusBadge({ status, views, stars }: TemplateStatusBadgeProps) {
|
||||
// const isPending = status === 'pending'
|
||||
// const label = isPending ? 'Under review' : 'Live'
|
||||
|
||||
const statsText =
|
||||
status === 'approved' && views !== undefined && views > 0
|
||||
? `${views} views${stars !== undefined && stars > 0 ? ` • ${stars} stars` : ''}`
|
||||
: null
|
||||
// const statsText =
|
||||
// status === 'approved' && views !== undefined && views > 0
|
||||
// ? `${views} views${stars !== undefined && stars > 0 ? ` • ${stars} stars` : ''}`
|
||||
// : null
|
||||
|
||||
return (
|
||||
<Badge variant={isPending ? 'amber' : 'green'} size='lg' dot>
|
||||
{label}
|
||||
{statsText && <span>• {statsText}</span>}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
// return (
|
||||
// <Badge variant={isPending ? 'amber' : 'green'} size='lg' dot>
|
||||
// {label}
|
||||
// {statsText && <span>• {statsText}</span>}
|
||||
// </Badge>
|
||||
// )
|
||||
// }
|
||||
|
||||
interface GeneralFooterProps {
|
||||
isDeployed?: boolean
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function WorkflowLoading() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -201,8 +201,6 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[1001]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
@@ -2478,6 +2476,7 @@ const WorkflowContent = React.memo(
|
||||
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
const [lastInteractedNodeId, setLastInteractedNodeId] = useState<string | null>(null)
|
||||
|
||||
const selectedNodeIds = useMemo(
|
||||
() => displayNodes.filter((node) => node.selected).map((node) => node.id),
|
||||
@@ -2489,6 +2488,14 @@ const WorkflowContent = React.memo(
|
||||
syncPanelWithSelection(selectedNodeIds)
|
||||
}, [selectedNodeIdsKey])
|
||||
|
||||
// Keep the most recently selected block on top even after deselection, so a
|
||||
// dragged block doesn't suddenly drop behind other overlapping blocks.
|
||||
useEffect(() => {
|
||||
if (selectedNodeIds.length > 0) {
|
||||
setLastInteractedNodeId(selectedNodeIds[selectedNodeIds.length - 1])
|
||||
}
|
||||
}, [selectedNodeIdsKey])
|
||||
|
||||
useEffect(() => {
|
||||
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
|
||||
if (pendingSelection && pendingSelection.length > 0) {
|
||||
@@ -3723,18 +3730,58 @@ const WorkflowContent = React.memo(
|
||||
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
// Elevate nodes using React Flow's native zIndex so selected/recent blocks
|
||||
// always sit above edges and other blocks.
|
||||
//
|
||||
// Z-index layers (regular blocks):
|
||||
// 21 — default
|
||||
// 22 — last interacted (dragged/selected, now deselected) so it stays on
|
||||
// top of siblings until another block is touched
|
||||
// 31 — currently selected (above connected edges at z-22 and handles at z-30)
|
||||
//
|
||||
// Subflow container nodes are skipped — they use depth-based zIndex for
|
||||
// correct parent/child layering and must not be bumped.
|
||||
// Child blocks inside containers already carry zIndex 1000 and are bumped by
|
||||
// +10 when selected so they stay above their sibling child blocks.
|
||||
const nodesForRender = useMemo(() => {
|
||||
return displayNodes.map((node) => {
|
||||
if (node.type === 'subflowNode') return node
|
||||
const base = node.zIndex ?? 21
|
||||
const target = node.selected
|
||||
? base + 10
|
||||
: node.id === lastInteractedNodeId
|
||||
? Math.max(base + 1, 22)
|
||||
: base
|
||||
if (target === (node.zIndex ?? 21)) return node
|
||||
return { ...node, zIndex: target }
|
||||
})
|
||||
}, [displayNodes, lastInteractedNodeId])
|
||||
|
||||
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
|
||||
const edgesWithSelection = useMemo(() => {
|
||||
const nodeMap = new Map(displayNodes.map((n) => [n.id, n]))
|
||||
const elevatedNodeIdSet = new Set(
|
||||
lastInteractedNodeId ? [...selectedNodeIds, lastInteractedNodeId] : selectedNodeIds
|
||||
)
|
||||
|
||||
return edgesForDisplay.map((edge) => {
|
||||
const sourceNode = nodeMap.get(edge.source)
|
||||
const targetNode = nodeMap.get(edge.target)
|
||||
const parentLoopId = sourceNode?.parentId || targetNode?.parentId
|
||||
const edgeContextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`
|
||||
const connectedToElevated =
|
||||
elevatedNodeIdSet.has(edge.source) || elevatedNodeIdSet.has(edge.target)
|
||||
// Derive elevated z-index from connected nodes so edges inside subflows
|
||||
// (child nodes at z-1000) stay above their sibling child blocks.
|
||||
const elevatedZIndex = Math.max(
|
||||
22,
|
||||
(sourceNode?.zIndex ?? 21) + 1,
|
||||
(targetNode?.zIndex ?? 21) + 1
|
||||
)
|
||||
|
||||
return {
|
||||
...edge,
|
||||
zIndex: connectedToElevated ? elevatedZIndex : 0,
|
||||
data: {
|
||||
...edge.data,
|
||||
isSelected: selectedEdges.has(edgeContextId),
|
||||
@@ -3745,7 +3792,14 @@ const WorkflowContent = React.memo(
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [edgesForDisplay, displayNodes, selectedEdges, handleEdgeDelete])
|
||||
}, [
|
||||
edgesForDisplay,
|
||||
displayNodes,
|
||||
selectedNodeIds,
|
||||
selectedEdges,
|
||||
handleEdgeDelete,
|
||||
lastInteractedNodeId,
|
||||
])
|
||||
|
||||
/** Handles Delete/Backspace to remove selected edges or blocks. */
|
||||
useEffect(() => {
|
||||
@@ -3885,7 +3939,7 @@ const WorkflowContent = React.memo(
|
||||
{showTrainingModal && <TrainingModal />}
|
||||
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
nodes={nodesForRender}
|
||||
edges={edgesWithSelection}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
@@ -3952,7 +4006,7 @@ const WorkflowContent = React.memo(
|
||||
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={snapGrid}
|
||||
elevateEdgesOnSelect={true}
|
||||
elevateEdgesOnSelect={false}
|
||||
onlyRenderVisibleElements={false}
|
||||
deleteKeyCode={null}
|
||||
elevateNodesOnSelect={false}
|
||||
|
||||
@@ -197,7 +197,10 @@ export function Preview({
|
||||
const childTraceSpans = extractChildTraceSpans(blockExecution)
|
||||
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
|
||||
|
||||
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
|
||||
const workflowName =
|
||||
childWorkflowState.metadata?.name ||
|
||||
(blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName ||
|
||||
'Nested Workflow'
|
||||
|
||||
setWorkflowStack((prev) => [
|
||||
...prev,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
|
||||
import {
|
||||
useCreateWorkspace,
|
||||
@@ -33,7 +33,6 @@ export function useWorkspaceManagement({
|
||||
sessionUserId,
|
||||
}: UseWorkspaceManagementProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const queryClient = useQueryClient()
|
||||
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
|
||||
|
||||
@@ -50,12 +49,10 @@ export function useWorkspaceManagement({
|
||||
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
|
||||
const pathnameRef = useRef<string | null>(pathname || null)
|
||||
const hasValidatedRef = useRef<boolean>(false)
|
||||
|
||||
workspaceIdRef.current = workspaceId
|
||||
routerRef.current = router
|
||||
pathnameRef.current = pathname || null
|
||||
|
||||
const activeWorkspace = useMemo(() => {
|
||||
if (!workspaces.length) return null
|
||||
@@ -114,16 +111,7 @@ export function useWorkspaceManagement({
|
||||
|
||||
try {
|
||||
await switchToWorkspace(workspace.id)
|
||||
const currentPath = pathnameRef.current || ''
|
||||
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
|
||||
if (templateDetailMatch) {
|
||||
const templateId = templateDetailMatch[1]
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`)
|
||||
} else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/templates`)
|
||||
} else {
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/home`)
|
||||
}
|
||||
routerRef.current?.push(`/workspace/${workspace.id}/home`)
|
||||
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
|
||||
} catch (error) {
|
||||
logger.error('Error switching workspace:', error)
|
||||
|
||||
@@ -919,22 +919,22 @@ export const Sidebar = memo(function Sidebar() {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-templates',
|
||||
handler: () => {
|
||||
try {
|
||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
if (pathWorkspaceId) {
|
||||
navigateToPage(`/workspace/${pathWorkspaceId}/templates`)
|
||||
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to navigate to templates', { err })
|
||||
}
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 'goto-templates',
|
||||
// handler: () => {
|
||||
// try {
|
||||
// const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
// if (pathWorkspaceId) {
|
||||
// navigateToPage(`/workspace/${pathWorkspaceId}/templates`)
|
||||
// logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
|
||||
// } else {
|
||||
// logger.warn('No workspace ID found, cannot navigate to templates')
|
||||
// }
|
||||
// } catch (err) {
|
||||
// logger.error('Failed to navigate to templates', { err })
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 'goto-logs',
|
||||
handler: () => {
|
||||
|
||||
11
apps/sim/app/workspace/[workspaceId]/w/loading.tsx
Normal file
11
apps/sim/app/workspace/[workspaceId]/w/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function WorkflowsLoading() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { A2AIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { IntegrationType } from '@/blocks/types'
|
||||
import { normalizeFileInput } from '@/blocks/utils'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
@@ -63,6 +64,8 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
|
||||
'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim workflows.',
|
||||
docsLink: 'https://docs.sim.ai/blocks/a2a',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.DeveloperTools,
|
||||
tags: ['agentic', 'automation'],
|
||||
bgColor: '#4151B5',
|
||||
icon: A2AIcon,
|
||||
subBlocks: [
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getApiKeyCondition, getModelOptions, RESPONSE_FORMAT_WAND_CONFIG } from '@/blocks/utils'
|
||||
import {
|
||||
getBaseModelProviders,
|
||||
@@ -69,6 +69,8 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
`,
|
||||
docsLink: 'https://docs.sim.ai/blocks/agent',
|
||||
category: 'blocks',
|
||||
integrationType: IntegrationType.AI,
|
||||
tags: ['llm', 'agentic', 'automation'],
|
||||
bgColor: 'var(--brand-primary-hex)',
|
||||
icon: AgentIcon,
|
||||
subBlocks: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AhrefsIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import type { AhrefsResponse } from '@/tools/ahrefs/types'
|
||||
|
||||
export const AhrefsBlock: BlockConfig<AhrefsResponse> = {
|
||||
@@ -12,6 +12,8 @@ export const AhrefsBlock: BlockConfig<AhrefsResponse> = {
|
||||
'Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.',
|
||||
docsLink: 'https://docs.ahrefs.com/docs/api/reference/introduction',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Analytics,
|
||||
tags: ['seo', 'marketing', 'data-analytics'],
|
||||
bgColor: '#E0E0E0',
|
||||
icon: AhrefsIcon,
|
||||
subBlocks: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AirtableIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import type { AirtableResponse } from '@/tools/airtable/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
@@ -14,6 +14,8 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
'Integrates Airtable into the workflow. Can list bases, list tables (with schema), and create, get, list, or update records. Can also be used in trigger mode to trigger a workflow when an update is made to an Airtable table.',
|
||||
docsLink: 'https://docs.sim.ai/tools/airtable',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Databases,
|
||||
tags: ['spreadsheet', 'automation'],
|
||||
bgColor: '#E0E0E0',
|
||||
icon: AirtableIcon,
|
||||
subBlocks: [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user