chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import 'streamdown/styles.css'
|
||||
|
||||
interface DocSource {
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
|
||||
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
|
||||
const seen = new Set<string>()
|
||||
const sources: DocSource[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'tool-searchDocs') continue
|
||||
const output = (part as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) continue
|
||||
for (const item of output as DocSource[]) {
|
||||
if (!item?.url || seen.has(item.url)) continue
|
||||
seen.add(item.url)
|
||||
sources.push({ title: item.title, url: item.url })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
/** Concatenate the streamed text parts of a message. */
|
||||
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
|
||||
return parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => (part as unknown as { text: string }).text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
interface AskAIProps {
|
||||
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
|
||||
locale: string
|
||||
}
|
||||
|
||||
export function AskAI({ locale }: AskAIProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Stable transport; the locale is sent per-message (below) so it stays current
|
||||
// after a language switch instead of being frozen into the transport.
|
||||
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
|
||||
|
||||
const { messages, sendMessage, status, stop, error } = useChat({ transport })
|
||||
|
||||
const isBusy = status === 'submitted' || status === 'streaming'
|
||||
|
||||
// Jump to the bottom instantly when the panel opens (a mount transition).
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||
}, [open])
|
||||
|
||||
// Smooth-scroll as new messages stream in (an explicit re-orientation cue).
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const text = input.trim()
|
||||
if (!text || isBusy) return
|
||||
sendMessage({ text }, { body: { locale } })
|
||||
setInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Ask Sim'
|
||||
onClick={() => setOpen(true)}
|
||||
className='fixed right-4 bottom-4 z-50 flex h-11 items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-4 font-season text-[var(--text-body)] text-sm shadow-[var(--shadow-medium)] transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
|
||||
>
|
||||
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
||||
Ask Sim
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'>
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
|
||||
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
|
||||
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
||||
Ask Sim
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close'
|
||||
onClick={() => {
|
||||
stop()
|
||||
setOpen(false)
|
||||
}}
|
||||
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='size-[16px]' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
|
||||
{messages.length === 0 && (
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
Ask anything about building, deploying, and managing AI agents in Sim.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const text = getText(message.parts)
|
||||
// Only the in-progress (last) message should show the loading state.
|
||||
const isStreaming = isBusy && index === messages.length - 1
|
||||
const sources = message.role === 'assistant' ? getSources(message.parts) : []
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex flex-col gap-1.5',
|
||||
message.role === 'user' ? 'items-end' : 'items-start'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-w-full text-[var(--text-primary)] text-base'>
|
||||
{text ? (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
|
||||
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
|
||||
'[&_strong]:font-[600]',
|
||||
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
|
||||
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
|
||||
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Streamdown>
|
||||
) : isStreaming ? (
|
||||
'…'
|
||||
) : sources.length === 0 ? (
|
||||
<span className='text-[var(--text-muted)]'>No answer returned.</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{sources.length > 0 && (
|
||||
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.url}
|
||||
href={source.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
{source.title || source.url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className='px-3 pb-3'>
|
||||
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit(event)
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder='Ask Sim about the docs…'
|
||||
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
{isBusy ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Stop'
|
||||
onClick={() => stop()}
|
||||
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
||||
>
|
||||
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
aria-label='Send'
|
||||
disabled={!input.trim()}
|
||||
className={cn(
|
||||
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
input.trim()
|
||||
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
||||
: 'bg-[#808080]'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className='size-[16px] text-white dark:text-black' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageNeighbour {
|
||||
url: string
|
||||
name: ReactNode
|
||||
}
|
||||
|
||||
interface PageFooterProps {
|
||||
previous?: PageNeighbour
|
||||
next?: PageNeighbour
|
||||
}
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
href: 'https://x.com/simdotai',
|
||||
label: 'X (Twitter)',
|
||||
icon: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/simstudioai/sim',
|
||||
label: 'GitHub',
|
||||
icon: 'M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/Hr4UWYEcTT',
|
||||
label: 'Discord',
|
||||
icon: 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z',
|
||||
},
|
||||
] as const
|
||||
|
||||
export function PageFooter({ previous, next }: PageFooterProps) {
|
||||
return (
|
||||
<div className='mt-12'>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
{(previous || next) && (
|
||||
<div className='flex gap-2 py-3'>
|
||||
{previous ? (
|
||||
<Link
|
||||
href={previous.url}
|
||||
className='group flex flex-1 flex-col gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<span className='text-[var(--text-muted)] text-xs'>Previous</span>
|
||||
<span className='flex items-center gap-1.5 text-[var(--text-body)] text-sm transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
<ChevronLeft className='size-3.5 shrink-0' />
|
||||
{previous.name}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className='flex-1' />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={next.url}
|
||||
className='group flex flex-1 flex-col items-end gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<span className='text-[var(--text-muted)] text-xs'>Next</span>
|
||||
<span className='flex items-center gap-1.5 text-[var(--text-body)] text-sm transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
{next.name}
|
||||
<ChevronRight className='size-3.5 shrink-0' />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className='flex-1' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-4 pt-4 pb-6'>
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={link.label}
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
className='size-[16px] fill-[var(--text-muted)] transition-colors hover:fill-[var(--text-icon)]'
|
||||
>
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageNavigationArrowsProps {
|
||||
previous?: {
|
||||
url: string
|
||||
}
|
||||
next?: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
const ARROW_LINK_CLASS =
|
||||
'flex size-[30px] items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
|
||||
|
||||
export function PageNavigationArrows({ previous, next }: PageNavigationArrowsProps) {
|
||||
if (!previous && !next) return null
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{previous && (
|
||||
<Link
|
||||
href={previous.url}
|
||||
className={ARROW_LINK_CLASS}
|
||||
aria-label='Previous page'
|
||||
title='Previous page'
|
||||
>
|
||||
<ChevronLeft className='size-[16px]' />
|
||||
</Link>
|
||||
)}
|
||||
{next && (
|
||||
<Link href={next.url} className={ARROW_LINK_CLASS} aria-label='Next page' title='Next page'>
|
||||
<ChevronRight className='size-[16px]' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import type { Folder, Item, Separator } from 'fumadocs-core/page-tree'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function SidebarChevron({ open, className }: { open: boolean; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width='5'
|
||||
height='8'
|
||||
viewBox='0 0 6 10'
|
||||
fill='none'
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d='M1 1L5 5L1 9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const LANG_PREFIXES = i18n.languages.map((l) => `/${l}`)
|
||||
|
||||
function stripLangPrefix(path: string): string {
|
||||
for (const prefix of LANG_PREFIXES) {
|
||||
if (path === prefix) return '/'
|
||||
if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function isActive(url: string, pathname: string, nested = true): boolean {
|
||||
const normalizedPathname = stripLangPrefix(pathname)
|
||||
const normalizedUrl = stripLangPrefix(url)
|
||||
return (
|
||||
normalizedUrl === normalizedPathname ||
|
||||
(nested && normalizedPathname.startsWith(`${normalizedUrl}/`))
|
||||
)
|
||||
}
|
||||
|
||||
const ITEM_BASE =
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-muted)] text-sm transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]'
|
||||
const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)] font-medium text-[var(--text-primary)]'
|
||||
|
||||
const ITEM_DESKTOP =
|
||||
'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight'
|
||||
const ITEM_TEXT = 'lg:text-[var(--text-body)]'
|
||||
const ITEM_HOVER = 'lg:hover:bg-[var(--surface-3)]'
|
||||
const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]'
|
||||
|
||||
const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-medium'
|
||||
const FOLDER_HOVER = 'lg:hover:bg-[var(--surface-3)]'
|
||||
const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]'
|
||||
|
||||
export function SidebarItem({ item }: { item: Item }) {
|
||||
const pathname = usePathname()
|
||||
const active = isActive(item.url, pathname, false)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
ITEM_BASE,
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
ITEM_DESKTOP,
|
||||
ITEM_TEXT,
|
||||
!active && ITEM_HOVER,
|
||||
active && ITEM_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function isApiReferenceFolder(node: Folder): boolean {
|
||||
if (node.index?.url.includes('/api-reference/')) return true
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'page' && child.url.includes('/api-reference/')) return true
|
||||
if (child.type === 'folder' && isApiReferenceFolder(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const hasActiveChild = checkHasActiveChild(item, pathname)
|
||||
const isApiRef = isApiReferenceFolder(item)
|
||||
const isOnApiRefPage = stripLangPrefix(pathname).startsWith('/api-reference')
|
||||
const hasChildren = item.children.length > 0
|
||||
const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage)
|
||||
const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null)
|
||||
const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen
|
||||
const toggleOpen = () => setManualOpen({ pathname, open: !open })
|
||||
const active = item.index ? isActive(item.index.url, pathname, false) : false
|
||||
|
||||
if (item.index && !hasChildren) {
|
||||
return (
|
||||
<Link
|
||||
href={item.index.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
ITEM_BASE,
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
ITEM_DESKTOP,
|
||||
ITEM_TEXT,
|
||||
!active && ITEM_HOVER,
|
||||
active && ITEM_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col lg:mb-[0.0625rem]'>
|
||||
<div className='flex w-full items-center lg:gap-0.5'>
|
||||
{item.index ? (
|
||||
<>
|
||||
<Link
|
||||
href={item.index.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-[var(--text-muted)] hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]',
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
'lg:block lg:flex-1 lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-[13px] lg:leading-tight',
|
||||
FOLDER_TEXT,
|
||||
!active && FOLDER_HOVER,
|
||||
active && FOLDER_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className={cn(
|
||||
'rounded-md p-1 hover:bg-[var(--surface-active)]',
|
||||
'lg:cursor-pointer lg:rounded-md lg:p-1 lg:transition-colors lg:hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<SidebarChevron open={open} className='text-[var(--text-icon)]' />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-[var(--text-muted)] hover:bg-[var(--surface-active)]',
|
||||
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-left lg:text-[13px] lg:leading-tight',
|
||||
FOLDER_TEXT,
|
||||
FOLDER_HOVER
|
||||
)}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<SidebarChevron open={open} className='ml-auto text-[var(--text-icon)]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows,opacity] duration-200 ease-in-out',
|
||||
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='ml-4 flex flex-col gap-0.5 lg:hidden'>{children}</div>
|
||||
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-[var(--surface-active)] border-l pl-2.5 lg:block'>
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarSeparator({ item }: { item: Separator }) {
|
||||
return (
|
||||
<div
|
||||
data-separator
|
||||
className={cn('mt-5 mb-1.5 px-2', 'lg:relative lg:mt-0 lg:mb-1.5 lg:px-[13px] lg:pt-0')}
|
||||
>
|
||||
<div className='separator-divider hidden'>
|
||||
<div className='h-[20px]' />
|
||||
<div className='h-px bg-[var(--surface-active)]' />
|
||||
<div className='h-[20px]' />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-[var(--text-muted)] text-xs',
|
||||
'lg:font-semibold lg:text-[10px] lg:text-[var(--text-muted)] lg:uppercase lg:tracking-[0.06em]'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function checkHasActiveChild(node: Folder, pathname: string): boolean {
|
||||
if (node.index && isActive(node.index.url, pathname)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'page' && isActive(child.url, pathname)) {
|
||||
return true
|
||||
}
|
||||
if (child.type === 'folder' && checkHasActiveChild(child, pathname)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Link from 'next/link'
|
||||
import { SimWordmark } from '@/components/ui/sim-logo'
|
||||
import { SIM_SITE_URL } from '@/lib/urls'
|
||||
|
||||
/**
|
||||
* Docs footer - the same site link directory as the main app's landing
|
||||
* footer (`apps/sim/app/(landing)/components/footer`), ported here so both
|
||||
* apps share one consistent footer. Links that live on sim.ai are absolute
|
||||
* (docs.sim.ai is a different origin); links that live on docs.sim.ai itself
|
||||
* (Academy, API Reference, blocks, integrations guides, …) stay relative.
|
||||
*/
|
||||
|
||||
const LINK_CLASS =
|
||||
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
|
||||
interface FooterItem {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Enterprise', href: `${SIM_SITE_URL}/enterprise`, external: true },
|
||||
{ label: 'Mothership', href: '/mothership' },
|
||||
{ label: 'Workflows', href: '/introduction' },
|
||||
{ label: 'Knowledge Base', href: '/knowledgebase' },
|
||||
{ label: 'Tables', href: '/tables' },
|
||||
{ label: 'MCP', href: '/agents/mcp' },
|
||||
{ label: 'API', href: '/api-reference/getting-started' },
|
||||
{ label: 'Self Hosting', href: '/platform/self-hosting' },
|
||||
{ label: 'Status', href: 'https://status.sim.ai', external: true },
|
||||
]
|
||||
|
||||
const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: `${SIM_SITE_URL}/blog`, external: true },
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Compare', href: `${SIM_SITE_URL}/comparison`, external: true },
|
||||
{ label: 'Careers', href: `${SIM_SITE_URL}/careers`, external: true },
|
||||
{ label: 'Changelog', href: `${SIM_SITE_URL}/changelog`, external: true },
|
||||
{ label: 'Contact', href: `${SIM_SITE_URL}/contact`, external: true },
|
||||
]
|
||||
|
||||
/** Top model providers — mirrors the landing footer's top 8 catalog providers. */
|
||||
const MODEL_LINKS: FooterItem[] = [
|
||||
{ label: 'All Models', href: `${SIM_SITE_URL}/models`, external: true },
|
||||
{ label: 'OpenAI', href: `${SIM_SITE_URL}/models/openai`, external: true },
|
||||
{ label: 'Anthropic', href: `${SIM_SITE_URL}/models/anthropic`, external: true },
|
||||
{ label: 'Google', href: `${SIM_SITE_URL}/models/google`, external: true },
|
||||
{ label: 'DeepSeek', href: `${SIM_SITE_URL}/models/deepseek`, external: true },
|
||||
{ label: 'xAI', href: `${SIM_SITE_URL}/models/xai`, external: true },
|
||||
{ label: 'Cerebras', href: `${SIM_SITE_URL}/models/cerebras`, external: true },
|
||||
{ label: 'Groq', href: `${SIM_SITE_URL}/models/groq`, external: true },
|
||||
{ label: 'Sakana AI', href: `${SIM_SITE_URL}/models/sakana`, external: true },
|
||||
]
|
||||
|
||||
const BLOCK_LINKS: FooterItem[] = [
|
||||
{ label: 'Agent', href: '/workflows/blocks/agent' },
|
||||
{ label: 'Router', href: '/workflows/blocks/router' },
|
||||
{ label: 'Function', href: '/workflows/blocks/function' },
|
||||
{ label: 'Condition', href: '/workflows/blocks/condition' },
|
||||
{ label: 'API Block', href: '/workflows/blocks/api' },
|
||||
{ label: 'Workflow', href: '/workflows/blocks/workflow' },
|
||||
{ label: 'Parallel', href: '/workflows/blocks/parallel' },
|
||||
{ label: 'Guardrails', href: '/workflows/blocks/guardrails' },
|
||||
{ label: 'Evaluator', href: '/workflows/blocks/evaluator' },
|
||||
{ label: 'Loop', href: '/workflows/blocks/loop' },
|
||||
]
|
||||
|
||||
const INTEGRATION_LINKS: FooterItem[] = [
|
||||
{ label: 'All Integrations', href: `${SIM_SITE_URL}/integrations`, external: true },
|
||||
{ label: 'Slack', href: '/integrations/slack' },
|
||||
{ label: 'GitHub', href: '/integrations/github' },
|
||||
{ label: 'Gmail', href: '/integrations/gmail' },
|
||||
{ label: 'Notion', href: '/integrations/notion' },
|
||||
{ label: 'Salesforce', href: '/integrations/salesforce' },
|
||||
{ label: 'Jira', href: '/integrations/jira' },
|
||||
{ label: 'Linear', href: '/integrations/linear' },
|
||||
{ label: 'Supabase', href: '/integrations/supabase' },
|
||||
{ label: 'Stripe', href: '/integrations/stripe' },
|
||||
]
|
||||
|
||||
const SOCIAL_LINKS: FooterItem[] = [
|
||||
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://www.linkedin.com/company/simstudioai/',
|
||||
external: true,
|
||||
},
|
||||
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/simstudioai/sim',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const LEGAL_LINKS: FooterItem[] = [
|
||||
{ label: 'Terms of Service', href: `${SIM_SITE_URL}/terms`, external: true },
|
||||
{ label: 'Privacy Policy', href: `${SIM_SITE_URL}/privacy`, external: true },
|
||||
]
|
||||
|
||||
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{items.map(({ label, href, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={label} href={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className='mt-[120px] w-full border-[var(--border)] border-t bg-[var(--bg)] max-sm:mt-16 max-lg:mt-[88px]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
|
||||
<nav
|
||||
aria-label='Footer navigation'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
className='grid grid-cols-8 gap-x-8 gap-y-10 max-sm:grid-cols-2 max-sm:gap-y-8 max-lg:grid-cols-3'
|
||||
>
|
||||
<a
|
||||
href={SIM_SITE_URL}
|
||||
aria-label='Sim home'
|
||||
className='flex h-[18px] items-center max-lg:col-span-full max-lg:mb-2'
|
||||
>
|
||||
<SimWordmark />
|
||||
</a>
|
||||
|
||||
<FooterColumn title='Product' items={PRODUCT_LINKS} />
|
||||
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
|
||||
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
|
||||
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
|
||||
<FooterColumn title='Models' items={MODEL_LINKS} />
|
||||
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
|
||||
<FooterColumn title='Legal' items={LEGAL_LINKS} />
|
||||
</nav>
|
||||
|
||||
<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LanguageDropdown } from '@/components/ui/language-dropdown'
|
||||
import { SearchTrigger } from '@/components/ui/search-trigger'
|
||||
import { SimWordmark } from '@/components/ui/sim-logo'
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_TABS = [
|
||||
{
|
||||
label: 'Documentation',
|
||||
href: '/introduction',
|
||||
match: (p: string) => !p.includes('/api-reference') && !p.includes('/academy'),
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
label: 'Academy',
|
||||
href: '/academy',
|
||||
match: (p: string) => p.includes('/academy'),
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
label: 'API Reference',
|
||||
href: '/api-reference/getting-started',
|
||||
match: (p: string) => p.includes('/api-reference'),
|
||||
external: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className='sticky top-0 z-50 bg-[var(--bg)]/80 backdrop-blur-md backdrop-saturate-150'>
|
||||
<div className='hidden w-full flex-col lg:flex'>
|
||||
{/* Top row: logo, search, controls */}
|
||||
<div
|
||||
className='relative flex h-[52px] w-full items-center justify-between'
|
||||
style={{
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + var(--nav-inset))',
|
||||
paddingRight: 'calc(var(--toc-offset) + var(--nav-inset))',
|
||||
}}
|
||||
>
|
||||
<Link href='/' className='flex items-center'>
|
||||
<SimWordmark className='h-[18px]' />
|
||||
</Link>
|
||||
|
||||
<div className='-translate-x-1/2 absolute left-1/2 flex items-center justify-center'>
|
||||
<SearchTrigger />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<LanguageDropdown />
|
||||
<ThemeToggle />
|
||||
<ChipLink href='https://sim.ai' variant='primary'>
|
||||
Get started
|
||||
</ChipLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: navigation tabs — border on row, tabs overlap it */}
|
||||
<div
|
||||
className='flex h-[40px] items-stretch gap-6 border-[var(--border)]/20 border-b'
|
||||
style={{
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + var(--nav-inset))',
|
||||
}}
|
||||
>
|
||||
{NAV_TABS.map((tab) => {
|
||||
const isActive = !tab.external && tab.match(pathname)
|
||||
return (
|
||||
<Link
|
||||
key={tab.label}
|
||||
href={tab.href}
|
||||
{...(tab.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
className={cn(
|
||||
'-mb-px relative flex items-center border-b text-[14px] tracking-[-0.01em] transition-colors',
|
||||
isActive
|
||||
? 'border-[var(--text-muted)] font-[480] text-[var(--text-primary)]'
|
||||
: 'border-transparent font-[430] text-[var(--text-muted)] hover:border-[var(--border-1)] hover:text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{/* Invisible bold text reserves width to prevent layout shift */}
|
||||
<span className='invisible font-[480]'>{tab.label}</span>
|
||||
<span className='absolute'>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { Chip } from '@sim/emcn'
|
||||
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
export function LLMCopyButton({ content }: { content: string }) {
|
||||
const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content))
|
||||
|
||||
return (
|
||||
<Chip
|
||||
onClick={onClick}
|
||||
leftIcon={checked ? Check : Copy}
|
||||
aria-label={checked ? 'Copied to clipboard' : 'Copy page content'}
|
||||
>
|
||||
{checked ? 'Copied' : 'Copy page'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DocsPageType } from '@/lib/source'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Status-color mapping mirrored from the emcn `Badge` variants
|
||||
* (`apps/sim/components/emcn/components/badge/badge.tsx`) — `green`, `blue`,
|
||||
* `purple`, and `amber` over the shared `--badge-*` tokens.
|
||||
*/
|
||||
const CONFIG = {
|
||||
tutorial: {
|
||||
label: 'Tutorial',
|
||||
className: 'bg-[var(--badge-success-bg)] text-[var(--badge-success-text)]',
|
||||
},
|
||||
guide: {
|
||||
label: 'Guide',
|
||||
className: 'bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]',
|
||||
},
|
||||
reference: {
|
||||
label: 'Reference',
|
||||
className: 'bg-[var(--badge-purple-bg)] text-[var(--badge-purple-text)]',
|
||||
},
|
||||
concept: {
|
||||
label: 'Concept',
|
||||
className: 'bg-[var(--badge-amber-bg)] text-[var(--badge-amber-text)]',
|
||||
},
|
||||
} as const satisfies Record<DocsPageType, { label: string; className: string }>
|
||||
|
||||
interface PageTypeBadgeProps {
|
||||
type: DocsPageType
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Small label that tells the reader which Diátaxis mode a page is — learning,
|
||||
* task, lookup, or understanding. Rendered only when a page declares `type`.
|
||||
* Chrome matches the emcn `Badge` status variants (md size).
|
||||
*/
|
||||
export function PageTypeBadge({ type, className }: PageTypeBadgeProps) {
|
||||
const config = CONFIG[type]
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-[9px] py-0.5 font-medium font-season text-[12px] transition-colors',
|
||||
config.className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { serializeJsonLd } from '@/lib/json-ld'
|
||||
import { DOCS_BASE_URL } from '@/lib/urls'
|
||||
|
||||
interface StructuredDataProps {
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
lang: string
|
||||
dateModified?: string
|
||||
breadcrumb?: Array<{ name: string; url: string }>
|
||||
}
|
||||
|
||||
export function StructuredData({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
lang,
|
||||
dateModified,
|
||||
breadcrumb,
|
||||
}: StructuredDataProps) {
|
||||
const baseUrl = DOCS_BASE_URL
|
||||
|
||||
const articleStructuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TechArticle',
|
||||
headline: title,
|
||||
description: description,
|
||||
url: url,
|
||||
...(dateModified && { datePublished: dateModified }),
|
||||
...(dateModified && { dateModified }),
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim Team',
|
||||
url: baseUrl,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: baseUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${baseUrl}/static/logo.png`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url,
|
||||
},
|
||||
inLanguage: lang,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim Documentation',
|
||||
url: baseUrl,
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'ReadAction',
|
||||
target: url,
|
||||
},
|
||||
}
|
||||
|
||||
const breadcrumbStructuredData = breadcrumb && {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumb.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url,
|
||||
})),
|
||||
}
|
||||
|
||||
const softwareStructuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
applicationSubCategory: 'AI Workspace',
|
||||
operatingSystem: 'Any',
|
||||
description:
|
||||
'Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work.',
|
||||
url: baseUrl,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim Team',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
category: 'Developer Tools',
|
||||
},
|
||||
featureList: [
|
||||
'AI workspace for teams',
|
||||
'Mothership — natural language agent creation',
|
||||
'Visual workflow builder',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: serializeJsonLd(articleStructuredData),
|
||||
}}
|
||||
/>
|
||||
{breadcrumbStructuredData && (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: serializeJsonLd(breadcrumbStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{url === baseUrl && (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: serializeJsonLd(softwareStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn, getAssetUrl } from '@/lib/utils'
|
||||
import { Lightbox } from './lightbox'
|
||||
|
||||
interface ActionImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
enableLightbox?: boolean
|
||||
}
|
||||
|
||||
interface ActionVideoProps {
|
||||
src: string
|
||||
alt: string
|
||||
enableLightbox?: boolean
|
||||
}
|
||||
|
||||
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const openLightbox = () => setIsLightboxOpen(true)
|
||||
|
||||
const image = (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
|
||||
enableLightbox && 'transition-opacity group-hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group inline-block cursor-pointer rounded p-0 text-left'
|
||||
>
|
||||
{image}
|
||||
</button>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
type='image'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const startTimeRef = useRef(0)
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
const resolvedSrc = getAssetUrl(src)
|
||||
|
||||
const openLightbox = () => {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={resolvedSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={cn(
|
||||
'inline-block w-full max-w-[200px] rounded-lg border border-[var(--border-1)]',
|
||||
enableLightbox && 'transition-opacity group-hover:opacity-90'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group inline-block cursor-pointer rounded p-0 text-left'
|
||||
>
|
||||
{video}
|
||||
</button>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
type='video'
|
||||
startTime={startTimeRef.current}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import type * as React from 'react'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
|
||||
interface BlockInfoCardProps {
|
||||
type: string
|
||||
color: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Brightness above which a tile background is "clearly light" and a white
|
||||
* foreground icon would wash out. Mirrors apps/sim's LIGHT_TILE_THRESHOLD
|
||||
* (blocks/icon-color.ts) so monochrome `currentColor` icons (e.g. Daytona,
|
||||
* Notion) stay legible on white/pale tiles instead of white-on-white.
|
||||
*/
|
||||
const LIGHT_TILE_THRESHOLD = 0.75
|
||||
|
||||
function isLightTileColor(color: string): boolean {
|
||||
const hex = color.trim().replace('#', '').toLowerCase()
|
||||
let r: number
|
||||
let g: number
|
||||
let b: number
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
r = Number.parseInt(hex[0] + hex[0], 16)
|
||||
g = Number.parseInt(hex[1] + hex[1], 16)
|
||||
b = Number.parseInt(hex[2] + hex[2], 16)
|
||||
} else if (/^[0-9a-f]{6}$/.test(hex)) {
|
||||
r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > LIGHT_TILE_THRESHOLD
|
||||
}
|
||||
|
||||
export function BlockInfoCard({
|
||||
type,
|
||||
color,
|
||||
icon: IconComponent,
|
||||
}: BlockInfoCardProps): React.ReactNode {
|
||||
const ResolvedIcon = IconComponent || blockTypeToIconMap[type] || null
|
||||
const iconColorClass = isLightTileColor(color) ? 'text-black' : 'text-white'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='mb-6 flex items-center justify-center overflow-hidden rounded-lg p-8'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{ResolvedIcon ? (
|
||||
<ResolvedIcon className={`size-10 ${iconColorClass}`} />
|
||||
) : (
|
||||
<div className={`font-mono text-xl opacity-70 ${iconColorClass}`}>
|
||||
{type.substring(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CodeBlock as FumadocsCodeBlock } from 'fumadocs-ui/components/codeblock'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<FumadocsCodeBlock
|
||||
{...props}
|
||||
className={cn('!border !border-[var(--border)] !shadow-none', props.className)}
|
||||
Actions={({ className }) => (
|
||||
<div className={cn('empty:hidden', className)}>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={copied ? 'Copied Text' : 'Copy Text'}
|
||||
onClick={(e) => {
|
||||
const pre = (e.currentTarget as HTMLElement).closest('figure')?.querySelector('pre')
|
||||
if (pre) handleCopy(pre.textContent || '')
|
||||
}}
|
||||
className='cursor-pointer rounded-md p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-icon)]'
|
||||
>
|
||||
<span className='flex items-center justify-center'>
|
||||
{copied ? (
|
||||
<Check size={16} className='text-[var(--brand-accent)]' />
|
||||
) : (
|
||||
<Copy size={16} className='text-[var(--text-muted)]' />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { serializeJsonLd } from '@/lib/json-ld'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface FAQProps {
|
||||
items: FAQItem[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
function FAQItemRow({
|
||||
item,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
item: FAQItem
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
className='flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left font-[470] text-[0.875rem] text-[var(--text-body)] transition-colors hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-[14px] shrink-0 text-[var(--text-icon)] transition-transform duration-200',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{item.question}
|
||||
</button>
|
||||
<div
|
||||
className='grid transition-[grid-template-rows,opacity] duration-200 ease-in-out'
|
||||
style={{
|
||||
gridTemplateRows: isOpen ? '1fr' : '0fr',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='px-4 pt-2 pb-2.5 pl-11 text-[0.875rem] text-[var(--text-secondary)] leading-relaxed'>
|
||||
{item.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null)
|
||||
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqSchema) }}
|
||||
/>
|
||||
<h2 className='mb-4 font-[500] text-xl'>{title}</h2>
|
||||
<div className='border-[var(--border)] border-t border-b'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.question}
|
||||
className={cn(index !== items.length - 1 && 'border-[var(--border)] border-b')}
|
||||
>
|
||||
<FAQItemRow
|
||||
item={item}
|
||||
isOpen={openIndex === index}
|
||||
onToggle={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentPropsWithoutRef, useState } from 'react'
|
||||
import { Check, Link } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
|
||||
interface HeadingProps extends ComponentPropsWithoutRef<'h1'> {
|
||||
as?: HeadingTag
|
||||
}
|
||||
|
||||
export function Heading({ as, className, ...props }: HeadingProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const As = as ?? 'h1'
|
||||
|
||||
if (!props.id) {
|
||||
return <As className={className} {...props} />
|
||||
}
|
||||
|
||||
const copyHeadingLink = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const url = `${window.location.origin}${window.location.pathname}#${props.id}`
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
|
||||
// Update URL hash without scrolling
|
||||
window.history.pushState(null, '', `#${props.id}`)
|
||||
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback: just navigate to the anchor
|
||||
window.location.hash = props.id as string
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<As className={cn('group flex scroll-m-28 flex-row items-center gap-2', className)} {...props}>
|
||||
<a href={`#${props.id}`} className='peer' onClick={copyHeadingLink}>
|
||||
{props.children}
|
||||
</a>
|
||||
{copied ? (
|
||||
<Check
|
||||
aria-hidden
|
||||
className='size-[14px] shrink-0 text-[var(--brand-accent)] opacity-100 transition-opacity'
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
aria-hidden
|
||||
className='size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100'
|
||||
/>
|
||||
)}
|
||||
</As>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// Auto-generated file - do not edit manually
|
||||
// Generated by scripts/generate-docs.ts
|
||||
// Maps block types to their icon component references
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
AgentMailIcon,
|
||||
AgentPhoneIcon,
|
||||
AgiloftIcon,
|
||||
AhrefsIcon,
|
||||
AirtableIcon,
|
||||
AirweaveIcon,
|
||||
AlgoliaIcon,
|
||||
AmplitudeIcon,
|
||||
ApifyIcon,
|
||||
ApolloIcon,
|
||||
AppConfigIcon,
|
||||
ArxivIcon,
|
||||
AsanaIcon,
|
||||
AshbyIcon,
|
||||
AthenaIcon,
|
||||
AttioIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
BrainIcon,
|
||||
BrandfetchIcon,
|
||||
BrexIcon,
|
||||
BrightDataIcon,
|
||||
BrowserUseIcon,
|
||||
CalComIcon,
|
||||
CalendlyIcon,
|
||||
CirclebackIcon,
|
||||
ClayIcon,
|
||||
ClerkIcon,
|
||||
ClickHouseIcon,
|
||||
CloudFormationIcon,
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
CodePipelineIcon,
|
||||
ConfluenceIcon,
|
||||
ContextDevIcon,
|
||||
ConvexIcon,
|
||||
CrowdStrikeIcon,
|
||||
CursorIcon,
|
||||
DagsterIcon,
|
||||
DatabricksIcon,
|
||||
DatadogIcon,
|
||||
DatagmaIcon,
|
||||
DaytonaIcon,
|
||||
DevinIcon,
|
||||
DiscordIcon,
|
||||
DocumentIcon,
|
||||
DocuSignIcon,
|
||||
DowndetectorIcon,
|
||||
DropboxIcon,
|
||||
DropcontactIcon,
|
||||
DsPyIcon,
|
||||
DubIcon,
|
||||
DuckDuckGoIcon,
|
||||
DynamoDBIcon,
|
||||
ElasticsearchIcon,
|
||||
ElevenLabsIcon,
|
||||
EmailBisonIcon,
|
||||
EnrichmentIcon,
|
||||
EnrichSoIcon,
|
||||
EnrowIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
ExtendIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FindymailIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GammaIcon,
|
||||
GithubIcon,
|
||||
GitLabIcon,
|
||||
GmailIcon,
|
||||
GongIcon,
|
||||
GoogleAdsIcon,
|
||||
GoogleAppsheetIcon,
|
||||
GoogleBigQueryIcon,
|
||||
GoogleBooksIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleContactsIcon,
|
||||
GoogleDocsIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleFormsIcon,
|
||||
GoogleGroupsIcon,
|
||||
GoogleIcon,
|
||||
GoogleMapsIcon,
|
||||
GoogleMeetIcon,
|
||||
GooglePagespeedIcon,
|
||||
GoogleSheetsIcon,
|
||||
GoogleSlidesIcon,
|
||||
GoogleTasksIcon,
|
||||
GoogleTranslateIcon,
|
||||
GoogleVaultIcon,
|
||||
GrafanaIcon,
|
||||
GrainIcon,
|
||||
GranolaIcon,
|
||||
GreenhouseIcon,
|
||||
GreptileIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
IAMIcon,
|
||||
IcypeasIcon,
|
||||
IdentityCenterIcon,
|
||||
IncidentioIcon,
|
||||
InfisicalIcon,
|
||||
InstantlyIcon,
|
||||
IntercomIcon,
|
||||
JinaAIIcon,
|
||||
JiraIcon,
|
||||
JiraServiceManagementIcon,
|
||||
JupyterIcon,
|
||||
KalshiIcon,
|
||||
KetchIcon,
|
||||
LangsmithIcon,
|
||||
LatexIcon,
|
||||
LaunchDarklyIcon,
|
||||
LeadMagicIcon,
|
||||
LemlistIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
LinkupIcon,
|
||||
LinqIcon,
|
||||
LoopsIcon,
|
||||
LumaIcon,
|
||||
MailchimpIcon,
|
||||
MailgunIcon,
|
||||
MailServerIcon,
|
||||
Mem0Icon,
|
||||
MicrosoftDataverseIcon,
|
||||
MicrosoftExcelIcon,
|
||||
MicrosoftOneDriveIcon,
|
||||
MicrosoftPlannerIcon,
|
||||
MicrosoftSharepointIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
MillionVerifierIcon,
|
||||
MistralIcon,
|
||||
MondayIcon,
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NeverBounceIcon,
|
||||
NewRelicIcon,
|
||||
NotionIcon,
|
||||
ObsidianIcon,
|
||||
OktaIcon,
|
||||
OnePasswordIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
PackageSearchIcon,
|
||||
PagerDutyIcon,
|
||||
ParallelIcon,
|
||||
PeopleDataLabsIcon,
|
||||
PerplexityIcon,
|
||||
PersonaIcon,
|
||||
PineconeIcon,
|
||||
PipedriveIcon,
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
ProfoundIcon,
|
||||
ProspeoIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
QuartrIcon,
|
||||
QuiverIcon,
|
||||
RailwayIcon,
|
||||
RB2BIcon,
|
||||
RDSIcon,
|
||||
RedditIcon,
|
||||
RedisIcon,
|
||||
ReductoIcon,
|
||||
ResendIcon,
|
||||
RevenueCatIcon,
|
||||
RipplingIcon,
|
||||
RootlyIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SapConcurIcon,
|
||||
SapS4HanaIcon,
|
||||
SESIcon,
|
||||
SecretsManagerIcon,
|
||||
SendblueIcon,
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
ServiceNowIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SimilarwebIcon,
|
||||
SimTriggerIcon,
|
||||
SixtyfourIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SportmonksIcon,
|
||||
SQSIcon,
|
||||
SquareIcon,
|
||||
SshIcon,
|
||||
STSIcon,
|
||||
STTIcon,
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TailscaleIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TemporalIcon,
|
||||
TextractIcon,
|
||||
ThriveIcon,
|
||||
TinybirdIcon,
|
||||
TrelloIcon,
|
||||
TriggerDevIcon,
|
||||
TwilioIcon,
|
||||
TypeformIcon,
|
||||
UpstashIcon,
|
||||
UptimeRobotIcon,
|
||||
VantaIcon,
|
||||
VercelIcon,
|
||||
VideoIcon,
|
||||
WealthboxIcon,
|
||||
WebflowIcon,
|
||||
WhatsAppIcon,
|
||||
WikipediaIcon,
|
||||
WizaIcon,
|
||||
WordpressIcon,
|
||||
WorkdayIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
ZendeskIcon,
|
||||
ZepIcon,
|
||||
ZeroBounceIcon,
|
||||
ZoomIcon,
|
||||
ZoomInfoIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
agentmail: AgentMailIcon,
|
||||
agentphone: AgentPhoneIcon,
|
||||
agiloft: AgiloftIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
airtable: AirtableIcon,
|
||||
airweave: AirweaveIcon,
|
||||
algolia: AlgoliaIcon,
|
||||
amplitude: AmplitudeIcon,
|
||||
apify: ApifyIcon,
|
||||
apollo: ApolloIcon,
|
||||
appconfig: AppConfigIcon,
|
||||
arxiv: ArxivIcon,
|
||||
asana: AsanaIcon,
|
||||
ashby: AshbyIcon,
|
||||
athena: AthenaIcon,
|
||||
attio: AttioIcon,
|
||||
azure_devops: AzureIcon,
|
||||
box: BoxCompanyIcon,
|
||||
brandfetch: BrandfetchIcon,
|
||||
brex: BrexIcon,
|
||||
brightdata: BrightDataIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
calcom: CalComIcon,
|
||||
calendly: CalendlyIcon,
|
||||
circleback: CirclebackIcon,
|
||||
clay: ClayIcon,
|
||||
clerk: ClerkIcon,
|
||||
clickhouse: ClickHouseIcon,
|
||||
cloudflare: CloudflareIcon,
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
codepipeline: CodePipelineIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
context_dev: ContextDevIcon,
|
||||
convex: ConvexIcon,
|
||||
crowdstrike: CrowdStrikeIcon,
|
||||
cursor: CursorIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
dagster: DagsterIcon,
|
||||
databricks: DatabricksIcon,
|
||||
datadog: DatadogIcon,
|
||||
datagma: DatagmaIcon,
|
||||
daytona: DaytonaIcon,
|
||||
devin: DevinIcon,
|
||||
discord: DiscordIcon,
|
||||
docusign: DocuSignIcon,
|
||||
downdetector: DowndetectorIcon,
|
||||
dropbox: DropboxIcon,
|
||||
dropcontact: DropcontactIcon,
|
||||
dspy: DsPyIcon,
|
||||
dub: DubIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
emailbison: EmailBisonIcon,
|
||||
enrich: EnrichSoIcon,
|
||||
enrichment: EnrichmentIcon,
|
||||
enrow: EnrowIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
extend: ExtendIcon,
|
||||
extend_v2: ExtendIcon,
|
||||
fathom: FathomIcon,
|
||||
file: DocumentIcon,
|
||||
file_v2: DocumentIcon,
|
||||
file_v4: DocumentIcon,
|
||||
file_v5: DocumentIcon,
|
||||
findymail: FindymailIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies: FirefliesIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
gamma: GammaIcon,
|
||||
github: GithubIcon,
|
||||
github_v2: GithubIcon,
|
||||
gitlab: GitLabIcon,
|
||||
gmail: GmailIcon,
|
||||
gmail_v2: GmailIcon,
|
||||
gong: GongIcon,
|
||||
google_ads: GoogleAdsIcon,
|
||||
google_appsheet: GoogleAppsheetIcon,
|
||||
google_bigquery: GoogleBigQueryIcon,
|
||||
google_books: GoogleBooksIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_calendar_v2: GoogleCalendarIcon,
|
||||
google_contacts: GoogleContactsIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_maps: GoogleMapsIcon,
|
||||
google_meet: GoogleMeetIcon,
|
||||
google_pagespeed: GooglePagespeedIcon,
|
||||
google_search: GoogleIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_sheets_v2: GoogleSheetsIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_slides_v2: GoogleSlidesIcon,
|
||||
google_tasks: GoogleTasksIcon,
|
||||
google_translate: GoogleTranslateIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
granola: GranolaIcon,
|
||||
greenhouse: GreenhouseIcon,
|
||||
greptile: GreptileIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
iam: IAMIcon,
|
||||
icypeas: IcypeasIcon,
|
||||
identity_center: IdentityCenterIcon,
|
||||
imap: MailServerIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
infisical: InfisicalIcon,
|
||||
instantly: InstantlyIcon,
|
||||
intercom: IntercomIcon,
|
||||
intercom_v2: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
jira_service_management: JiraServiceManagementIcon,
|
||||
jupyter: JupyterIcon,
|
||||
kalshi: KalshiIcon,
|
||||
kalshi_v2: KalshiIcon,
|
||||
ketch: KetchIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
langsmith: LangsmithIcon,
|
||||
latex: LatexIcon,
|
||||
launchdarkly: LaunchDarklyIcon,
|
||||
leadmagic: LeadMagicIcon,
|
||||
lemlist: LemlistIcon,
|
||||
linear: LinearIcon,
|
||||
linear_v2: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linkup: LinkupIcon,
|
||||
linq: LinqIcon,
|
||||
loops: LoopsIcon,
|
||||
luma: LumaIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_ad: AzureIcon,
|
||||
microsoft_dataverse: MicrosoftDataverseIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
millionverifier: MillionVerifierIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
mistral_parse_v2: MistralIcon,
|
||||
mistral_parse_v3: MistralIcon,
|
||||
monday: MondayIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
neverbounce: NeverBounceIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
notion: NotionIcon,
|
||||
notion_v2: NotionIcon,
|
||||
obsidian: ObsidianIcon,
|
||||
okta: OktaIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
onepassword: OnePasswordIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
pagerduty: PagerDutyIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
peopledatalabs: PeopleDataLabsIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
persona: PersonaIcon,
|
||||
pinecone: PineconeIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
profound: ProfoundIcon,
|
||||
prospeo: ProspeoIcon,
|
||||
pulse: PulseIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
quartr: QuartrIcon,
|
||||
quiver: QuiverIcon,
|
||||
railway: RailwayIcon,
|
||||
rb2b: RB2BIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
redis: RedisIcon,
|
||||
reducto: ReductoIcon,
|
||||
reducto_v2: ReductoIcon,
|
||||
resend: ResendIcon,
|
||||
revenuecat: RevenueCatIcon,
|
||||
rippling: RipplingIcon,
|
||||
rootly: RootlyIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
sap_concur: SapConcurIcon,
|
||||
sap_s4hana: SapS4HanaIcon,
|
||||
secrets_manager: SecretsManagerIcon,
|
||||
sendblue: SendblueIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
ses: SESIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sharepoint_v2: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sim_workspace_event: SimTriggerIcon,
|
||||
similarweb: SimilarwebIcon,
|
||||
sixtyfour: SixtyfourIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
sportmonks: SportmonksIcon,
|
||||
sqs: SQSIcon,
|
||||
square: SquareIcon,
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
stripe: StripeIcon,
|
||||
sts: STSIcon,
|
||||
stt: STTIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tailscale: TailscaleIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
temporal: TemporalIcon,
|
||||
textract: TextractIcon,
|
||||
textract_v2: TextractIcon,
|
||||
thrive: ThriveIcon,
|
||||
tinybird: TinybirdIcon,
|
||||
trello: TrelloIcon,
|
||||
trigger_dev: TriggerDevIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
upstash: UpstashIcon,
|
||||
uptimerobot: UptimeRobotIcon,
|
||||
vanta: VantaIcon,
|
||||
vercel: VercelIcon,
|
||||
video_generator: VideoIcon,
|
||||
video_generator_v2: VideoIcon,
|
||||
vision: EyeIcon,
|
||||
vision_v2: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
wiza: WizaIcon,
|
||||
wordpress: WordpressIcon,
|
||||
workday: WorkdayIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zerobounce: ZeroBounceIcon,
|
||||
zoom: ZoomIcon,
|
||||
zoominfo: ZoomInfoIcon,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage, { type ImageProps as NextImageProps } from 'next/image'
|
||||
import { Lightbox } from '@/components/ui/lightbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ImageProps extends Omit<NextImageProps, 'className'> {
|
||||
className?: string
|
||||
enableLightbox?: boolean
|
||||
}
|
||||
|
||||
export function Image({
|
||||
className = 'w-full',
|
||||
enableLightbox = true,
|
||||
alt = '',
|
||||
src,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const openLightbox = () => setIsLightboxOpen(true)
|
||||
|
||||
const image = (
|
||||
<NextImage
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-[var(--border)] object-cover',
|
||||
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
alt={alt}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${alt} in media viewer`}
|
||||
className='group contents'
|
||||
>
|
||||
{image}
|
||||
</button>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={typeof src === 'string' ? src : String(src)}
|
||||
alt={alt}
|
||||
type='image'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { ChipDropdown } from '@sim/emcn'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
const languages = {
|
||||
en: { name: 'English', flag: '🇺🇸' },
|
||||
es: { name: 'Español', flag: '🇪🇸' },
|
||||
fr: { name: 'Français', flag: '🇫🇷' },
|
||||
de: { name: 'Deutsch', flag: '🇩🇪' },
|
||||
ja: { name: '日本語', flag: '🇯🇵' },
|
||||
zh: { name: '简体中文', flag: '🇨🇳' },
|
||||
}
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const { push } = useRouter()
|
||||
|
||||
const languageOptions = Object.entries(languages).map(([code, lang]) => ({
|
||||
value: code,
|
||||
label: lang.name,
|
||||
iconElement: <span className='text-[13px]'>{lang.flag}</span>,
|
||||
}))
|
||||
|
||||
const langFromParams = params?.lang as string
|
||||
const currentLang =
|
||||
langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
|
||||
|
||||
const handleLanguageChange = (locale: string) => {
|
||||
if (locale === currentLang) return
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (segments[0] && Object.keys(languages).includes(segments[0])) {
|
||||
segments.shift()
|
||||
}
|
||||
|
||||
let newPath = ''
|
||||
if (locale === 'en') {
|
||||
newPath = segments.length > 0 ? `/${segments.join('/')}` : '/introduction'
|
||||
} else {
|
||||
newPath = `/${locale}${segments.length > 0 ? `/${segments.join('/')}` : '/introduction'}`
|
||||
}
|
||||
|
||||
push(newPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipDropdown
|
||||
value={currentLang}
|
||||
onChange={handleLanguageChange}
|
||||
options={languageOptions}
|
||||
align='end'
|
||||
matchTriggerWidth={false}
|
||||
contentClassName='min-w-[160px]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
type: 'image' | 'video'
|
||||
startTime?: number
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const mediaButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const closeLightbox = useEffectEvent(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeLightbox()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
closeLightbox()
|
||||
}
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
mediaButtonRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) {
|
||||
videoRef.current.currentTime = startTime
|
||||
}
|
||||
}, [isOpen, startTime, type])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Media viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl'>
|
||||
<button
|
||||
ref={mediaButtonRef}
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
aria-label='Close media viewer'
|
||||
className='block cursor-pointer rounded-xl p-0 outline-none focus-visible:ring-2 focus-visible:ring-white/70'
|
||||
>
|
||||
{type === 'image' ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={getAssetUrl(src)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className='max-h-[75vh] max-w-[75vw] rounded-xl outline-none focus:outline-none'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ResponseSectionProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ResponseSection({ children }: ResponseSectionProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [statusCodes, setStatusCodes] = useState<string[]>([])
|
||||
const [selectedCode, setSelectedCode] = useState<string>('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function getAccordionItems() {
|
||||
const root = containerRef.current?.querySelector('[data-orientation="vertical"]')
|
||||
if (!root) return []
|
||||
return Array.from(root.children).filter(
|
||||
(el) => el.getAttribute('data-state') !== null
|
||||
) as HTMLElement[]
|
||||
}
|
||||
|
||||
function showStatusCode(code: string) {
|
||||
const items = getAccordionItems()
|
||||
for (const item of items) {
|
||||
const triggerBtn = item.querySelector('h3 button') as HTMLButtonElement | null
|
||||
const text = triggerBtn?.textContent?.trim() ?? ''
|
||||
const itemCode = text.match(/^\d{3}/)?.[0]
|
||||
|
||||
if (itemCode === code) {
|
||||
item.style.display = ''
|
||||
if (item.getAttribute('data-state') === 'closed' && triggerBtn) {
|
||||
triggerBtn.click()
|
||||
}
|
||||
} else {
|
||||
item.style.display = 'none'
|
||||
if (item.getAttribute('data-state') === 'open' && triggerBtn) {
|
||||
triggerBtn.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when the fumadocs accordion children mount via MutationObserver,
|
||||
* then extract status codes and show the first one.
|
||||
* Replaces the previous approach that used `children` as a dependency
|
||||
* (which triggered on every render since children is a new object each time).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const initialize = () => {
|
||||
const items = getAccordionItems()
|
||||
if (items.length === 0) return false
|
||||
|
||||
const codes: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
const triggerBtn = item.querySelector('h3 button')
|
||||
if (triggerBtn) {
|
||||
const text = triggerBtn.textContent?.trim() ?? ''
|
||||
const code = text.match(/^\d{3}/)?.[0]
|
||||
if (code && !seen.has(code)) {
|
||||
seen.add(code)
|
||||
codes.push(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codes.length > 0) {
|
||||
setStatusCodes(codes)
|
||||
setSelectedCode(codes[0])
|
||||
showStatusCode(codes[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (initialize()) return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (initialize()) {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleSelectCode(code: string) {
|
||||
setSelectedCode(code)
|
||||
setIsOpen(false)
|
||||
showStatusCode(code)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='response-section-wrapper'>
|
||||
{statusCodes.length > 0 && (
|
||||
<div className='response-section-header'>
|
||||
<h2 className='response-section-title'>Response</h2>
|
||||
<div className='response-section-meta'>
|
||||
<div ref={dropdownRef} className='response-section-dropdown-wrapper'>
|
||||
<button
|
||||
type='button'
|
||||
className='response-section-dropdown-trigger'
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span>{selectedCode}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'response-section-chevron',
|
||||
isOpen && 'response-section-chevron-open'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className='response-section-dropdown-menu'>
|
||||
{statusCodes.map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type='button'
|
||||
className={cn(
|
||||
'response-section-dropdown-item',
|
||||
code === selectedCode && 'response-section-dropdown-item-selected'
|
||||
)}
|
||||
onClick={() => handleSelectCode(code)}
|
||||
>
|
||||
<span>{code}</span>
|
||||
{code === selectedCode && (
|
||||
<svg
|
||||
className='response-section-check'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
>
|
||||
<polyline points='20 6 9 17 4 12' />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className='response-section-content-type'>application/json</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='response-section-content'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
chipContentIconClass,
|
||||
chipFilledFillTokens,
|
||||
chipGeometryClass,
|
||||
TRIGGER_BORDER_CLASS,
|
||||
} from '@sim/emcn'
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SearchTrigger() {
|
||||
const openSearchDialog = () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
data-search-trigger
|
||||
className={cn(
|
||||
chipGeometryClass,
|
||||
chipFilledFillTokens,
|
||||
TRIGGER_BORDER_CLASS,
|
||||
'flex w-[360px] cursor-pointer font-season text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={openSearchDialog}
|
||||
>
|
||||
<Search className={chipContentIconClass} />
|
||||
<span>Search…</span>
|
||||
<kbd className='ml-auto flex items-center'>
|
||||
<span className='text-[15px]'>⌘</span>
|
||||
<span className='text-[12px]'>K</span>
|
||||
</kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useId } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SimLogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "sim" wordmark, no separate icon mark — same paths as the landing
|
||||
* footer's `SimWordmark` (`apps/sim/app/(landing)/components/navbar/components/sim-wordmark`),
|
||||
* ported here so the docs footer can match it exactly. Filled with
|
||||
* `var(--text-body)` so it reads as one solid ink matching surrounding text.
|
||||
*/
|
||||
export function SimWordmark({ className }: SimLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 441 212'
|
||||
width={37}
|
||||
height={18}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
className={cn('-translate-y-[1.5px] h-[18px] w-auto', className)}
|
||||
>
|
||||
<g fill='var(--text-body)'>
|
||||
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
|
||||
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
|
||||
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
|
||||
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only Sim mark, no wordmark text. Same brandbook icon geometry as
|
||||
* {@link SimLogoFull}'s icon, at its native square viewBox.
|
||||
*/
|
||||
export function SimLogoIcon({ className }: SimLogoProps) {
|
||||
const gradientId = `sim-logo-icon-gradient-${useId()}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 222 222'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={cn('size-7', className)}
|
||||
aria-label='Sim'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='129.434'
|
||||
y1='129.266'
|
||||
x2='185.629'
|
||||
y2='185.33'
|
||||
>
|
||||
<stop offset='0' />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='m107.822 93.7612c0 3.5869-1.419 7.0308-3.938 9.5668l-.361.364c-2.517 2.544-5.9375 3.966-9.4994 3.966h-80.5781c-7.42094 0-13.4455 6.06-13.4455 13.533v87.141c0 7.474 6.02456 13.534 13.4455 13.534h86.5167c7.4208 0 13.4378-6.06 13.4378-13.534v-81.587c0-3.326 1.31-6.517 3.647-8.871 2.33-2.347 5.499-3.667 8.802-3.667h81.928c7.421 0 13.437-6.059 13.437-13.533v-87.1407c0-7.47374-6.016-13.5333-13.437-13.5333h-86.517c-7.421 0-13.438 6.05956-13.438 13.5333zm26.256-75.2112h60.874c4.337 0 7.844 3.5393 7.844 7.9003v61.3071c0 4.3604-3.507 7.9003-7.844 7.9003h-60.874c-4.33 0-7.845-3.5399-7.845-7.9003v-61.3071c0-4.361 3.515-7.9003 7.845-7.9003z'
|
||||
fill='#33C482'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m207.878 129.57h-64.324c-7.798 0-14.12 6.367-14.12 14.221v63.993c0 7.854 6.322 14.221 14.12 14.221h64.324c7.799 0 14.121-6.367 14.121-14.221v-63.993c0-7.854-6.322-14.221-14.121-14.221z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
<path
|
||||
d='m207.878 129.266h-64.324c-7.798 0-14.12 6.366-14.12 14.221v63.992c0 7.854 6.322 14.22 14.12 14.22h64.324c7.799 0 14.121-6.366 14.121-14.22v-63.992c0-7.855-6.322-14.221-14.121-14.221z'
|
||||
fill={`url(#${gradientId})`}
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Sim logo with icon and "Sim" text.
|
||||
* Uses the same SVG source as the landing page navbar for exact visual alignment.
|
||||
* The icon stays green (#33C482), text adapts to light/dark mode.
|
||||
*/
|
||||
export function SimLogoFull({ className }: SimLogoProps) {
|
||||
const gradientId = `sim-logo-full-gradient-${useId()}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 71 22'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={cn('h-7 w-auto', className)}
|
||||
aria-label='Sim'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='171.406'
|
||||
y1='171.18'
|
||||
x2='245.831'
|
||||
y2='245.428'
|
||||
>
|
||||
<stop offset='0' />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Green icon — scaled to match landing logo proportions */}
|
||||
<g transform='scale(.07483)'>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='m142.79 124.17c0 4.75-1.88 9.31-5.22 12.67l-.48.48c-3.33 3.37-7.86 5.25-12.58 5.25h-106.71c-9.83 0-17.81 8.03-17.81 17.92v115.41c0 9.9 7.98 17.92 17.81 17.92h114.58c9.83 0 17.8-8.03 17.8-17.92v-108.05c0-4.41 1.74-8.63 4.83-11.75 3.09-3.11 7.28-4.86 11.66-4.86h108.5c9.83 0 17.8-8.02 17.8-17.92v-115.41c0-9.9-7.97-17.92-17.8-17.92h-114.58c-9.83 0-17.8 8.03-17.8 17.92zm34.77-99.61h80.62c5.74 0 10.39 4.69 10.39 10.46v81.19c0 5.77-4.64 10.46-10.39 10.46h-80.62c-5.73 0-10.39-4.69-10.39-10.46v-81.19c0-5.78 4.66-10.46 10.39-10.46z'
|
||||
fill='#33C482'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill={`url(#${gradientId})`}
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
</g>
|
||||
{/* "Sim" text — adapts to light/dark mode */}
|
||||
<g className='fill-[var(--text-primary)]'>
|
||||
<path d='M31.57 15.85h2.59c0 .71.26 1.28.78 1.71.52.41 1.22.61 2.1.61.96 0 1.7-.18 2.21-.55.52-.39.78-.9.78-1.53 0-.46-.14-.85-.43-1.16-.27-.31-.77-.56-1.49-.75l-2.47-.58c-1.25-.31-2.17-.78-2.79-1.42-.59-.64-.89-1.48-.89-2.52 0-.87.22-1.62.66-2.26.46-.64 1.08-1.13 1.87-1.48.8-.35 1.72-.52 2.76-.52s1.93.18 2.67.55c.77.37 1.36.88 1.78 1.53.44.66.67 1.44.69 2.35h-2.59c-.02-.73-.26-1.3-.72-1.71-.46-.41-1.1-.61-1.93-.61-.84 0-1.49.18-1.95.55-.46.37-.69.87-.69 1.51 0 .95.69 1.59 2.07 1.94l2.47.61c1.19.27 2.08.71 2.67 1.33.59.6.89 1.42.89 2.46 0 .89-.24 1.67-.72 2.35-.48.66-1.14 1.17-1.98 1.53-.82.35-1.8.52-2.93.52-1.65 0-2.96-.41-3.94-1.22-.98-.81-1.47-1.89-1.47-3.24z' />
|
||||
<path d='M44.51 19.96v-14.16c1.08.39 1.55.39 2.7 0v14.16zm1.32-15.09c-.48 0-.9-.17-1.26-.52-.34-.37-.52-.79-.52-1.27 0-.5.17-.93.52-1.27.36-.35.79-.52 1.26-.52.5 0 .92.17 1.26.52s.52.77.52 1.27c0 .48-.17.91-.52 1.27-.34.35-.77.52-1.26.52z' />
|
||||
<path d='M51.98 19.96h-2.7v-14.16h2.41v2.39c.29-.79.84-1.46 1.61-1.98.79-.54 1.73-.81 2.85-.81 1.25 0 2.28.34 3.1 1.01.82.68 1.36 1.57 1.61 2.69h-.49c.19-1.12.72-2.02 1.58-2.69.86-.68 1.93-1.01 3.19-1.01 1.61 0 2.87.47 3.79 1.42.92.95 1.38 2.24 1.38 3.88v9.26h-2.64v-8.6c0-1.12-.29-1.98-.86-2.58-.56-.62-1.31-.93-2.27-.93-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.56-.43 1.22-.43 1.97v8.4h-2.67v-8.63c0-1.12-.28-1.97-.83-2.55-.56-.6-1.31-.9-2.27-.9-.67 0-1.26.15-1.78.46-.5.29-.89.71-1.18 1.27-.29.54-.43 1.19-.43 1.94z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import type { SVGProps } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='4' />
|
||||
<path d='M12 2v2' />
|
||||
<path d='M12 20v2' />
|
||||
<path d='m4.93 4.93 1.41 1.41' />
|
||||
<path d='m17.66 17.66 1.41 1.41' />
|
||||
<path d='M2 12h2' />
|
||||
<path d='M20 12h2' />
|
||||
<path d='m6.34 17.66-1.41 1.41' />
|
||||
<path d='m19.07 4.93-1.41 1.41' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex size-[30px] cursor-pointer items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
<SunIcon className='block size-[14px] dark:hidden' />
|
||||
<MoonIcon className='hidden size-[14px] dark:block' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Parse a chapter timestamp ("M:SS" or "H:MM:SS") into seconds. */
|
||||
function parseTime(time: string): number {
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.some(Number.isNaN)) return 0
|
||||
return parts.reduce((acc, n) => acc * 60 + n, 0)
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
/** Chapter label. */
|
||||
title: string
|
||||
/** Timestamp, e.g. "0:45". */
|
||||
time?: string
|
||||
}
|
||||
|
||||
interface VideoChaptersProps {
|
||||
/** Panel heading. Defaults to "Chapters". */
|
||||
title?: string
|
||||
chapters: Chapter[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-rail list of the current video's chapters — flat and borderless to
|
||||
* match the docs' "On this page" TOC (small muted label, hover-highlighted
|
||||
* rows). Rows are skip-to controls; they activate once the lesson's video is
|
||||
* recorded.
|
||||
*/
|
||||
export function VideoChapters({ title = 'Chapters', chapters, className }: VideoChaptersProps) {
|
||||
// Chapters only seek when a VideoPlaceholder with a real video is on the page.
|
||||
// Handshake so the rows stay inert (not falsely clickable) on video-less lessons.
|
||||
const [hasVideo, setHasVideo] = useState(false)
|
||||
useEffect(() => {
|
||||
const onReady = () => setHasVideo(true)
|
||||
window.addEventListener('academy:video-ready', onReady)
|
||||
window.dispatchEvent(new Event('academy:video-query'))
|
||||
return () => window.removeEventListener('academy:video-ready', onReady)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className={cn('not-prose', className)}>
|
||||
<p className='mb-2 px-2.5 font-medium text-[0.8125rem] text-[var(--text-muted)]'>{title}</p>
|
||||
<ul className='m-0 flex list-none flex-col gap-0.5 p-0'>
|
||||
{chapters.map((chapter) => (
|
||||
<li key={chapter.title}>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!hasVideo || chapter.time == null}
|
||||
onClick={() => {
|
||||
if (chapter.time == null) return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('academy:seek', { detail: { time: parseTime(chapter.time) } })
|
||||
)
|
||||
}}
|
||||
className='flex w-full cursor-pointer items-baseline gap-3 rounded-lg px-2.5 py-2 text-left text-[var(--text-secondary)] text-sm transition-colors hover:bg-[var(--surface-active)] disabled:cursor-default disabled:hover:bg-transparent'
|
||||
>
|
||||
<span className='min-w-0 flex-1 break-words'>{chapter.title}</span>
|
||||
{chapter.time && (
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-xs tabular-nums'>
|
||||
{chapter.time}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn, getAssetUrl } from '@/lib/utils'
|
||||
|
||||
interface VideoPlaceholderProps {
|
||||
/** Large title shown on the hero. */
|
||||
title?: string
|
||||
/** Small italic eyebrow above the title, e.g. a module name. */
|
||||
eyebrow?: string
|
||||
/** Pill in the top-right corner. Defaults to "Coming soon" (shown only until a video is set). */
|
||||
label?: string
|
||||
/**
|
||||
* Self-hosted video source. Accepts an absolute URL, a root-relative path
|
||||
* (`/static/...`), or a bare asset name resolved through the Blob CDN. When
|
||||
* set, the play button loads the video; otherwise the card is "coming soon".
|
||||
*/
|
||||
src?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Resolve a video source: pass absolute/root-relative through, send bare names to the Blob CDN. */
|
||||
function resolveVideoSrc(src: string): string {
|
||||
if (/^https?:\/\//.test(src) || src.startsWith('/')) return src
|
||||
return getAssetUrl(src)
|
||||
}
|
||||
|
||||
/** The sim logotype, drawn with currentColor so the theme can tint it. */
|
||||
function SimWordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox='0 0 816 392' fill='currentColor' aria-label='Sim' className={className}>
|
||||
<path d='M 0 297.507 L 54.609 297.507 C 54.609 312.642 60.07 324.71 70.992 333.709 C 81.914 342.299 96.679 346.594 115.287 346.594 C 135.512 346.594 151.086 342.707 162.008 334.936 C 172.93 326.754 178.391 315.915 178.391 302.415 C 178.391 292.598 175.357 284.417 169.289 277.871 C 163.627 271.326 153.109 266.009 137.737 261.918 L 85.555 249.646 C 59.261 243.102 39.642 233.08 26.698 219.581 C 14.158 206.082 7.888 188.287 7.888 166.198 C 7.888 147.79 12.54 131.837 21.844 118.338 C 31.552 104.838 44.699 94.408 61.284 87.045 C 78.274 79.682 97.69 76 119.534 76 C 141.378 76 160.187 79.886 175.964 87.658 C 192.144 95.43 204.684 106.271 213.584 120.179 C 222.888 134.086 227.742 150.654 228.146 169.88 L 173.536 169.88 C 173.132 154.335 168.076 142.267 158.368 133.678 C 148.659 125.087 135.108 120.792 117.714 120.792 C 99.915 120.792 86.162 124.678 76.453 132.451 C 66.745 140.223 61.891 150.858 61.891 164.357 C 61.891 184.402 76.453 198.105 105.579 205.468 L 157.76 218.354 C 182.841 224.08 201.651 233.489 214.191 246.579 C 226.73 259.26 233 276.644 233 298.734 C 233 317.55 227.943 334.118 217.831 348.435 C 207.718 362.343 193.762 373.183 175.964 380.955 C 158.57 388.318 137.939 392 114.073 392 C 79.285 392 51.576 383.409 30.945 366.229 C 10.315 349.048 0 326.141 0 297.507 Z' />
|
||||
<path d='M 430.759 392 L 374 392 L 374 92 L 424.721 92 L 424.721 143.095 C 430.76 126.357 442.433 112.167 458.535 101.145 C 475.039 89.715 494.966 84 518.314 84 C 544.48 84 566.217 91.144 583.527 105.431 C 600.837 119.719 612.108 138.701 617.342 162.378 L 607.076 162.378 C 611.102 138.701 622.172 119.719 640.287 105.431 C 658.401 91.144 680.743 84 707.311 84 C 741.126 84 767.694 94.001 787.017 114.004 C 806.339 134.006 816 161.357 816 196.056 L 816 392 L 760.448 392 L 760.448 210.139 C 760.448 186.462 754.41 168.297 742.333 155.643 C 730.66 142.579 714.758 136.048 694.631 136.048 C 680.542 136.048 668.062 139.314 657.194 145.845 C 646.728 151.968 638.475 160.949 632.437 172.787 C 626.398 184.625 623.38 198.505 623.38 214.425 L 623.38 392 L 567.223 392 L 567.223 209.527 C 567.223 185.85 561.387 167.888 549.713 155.643 C 538.039 142.988 522.138 136.66 502.01 136.66 C 487.921 136.66 475.442 139.926 464.574 146.457 C 454.108 152.58 445.855 161.562 439.817 173.4 C 433.778 184.83 430.759 198.505 430.759 214.425 L 430.759 392 Z' />
|
||||
<path d='M 342 38 C 342 58.987 324.987 76 304 76 C 283.013 76 266 58.987 266 38 C 266 17.013 283.013 0 304 0 C 324.987 0 342 17.013 342 38 Z' />
|
||||
<path d='M 332 392 L 276 392 L 276 92 C 284.5 95.988 293.99 98.218 304 98.218 C 314.01 98.218 323.5 95.988 332 92 L 332 392 Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A 16:9 lesson hero used across the Academy. Always shows the design-system
|
||||
* video card (title, blueprint grid, theme-aware dark/light). When a `src` is
|
||||
* provided the play button loads the self-hosted video inline; otherwise the
|
||||
* card reads "Coming soon" and the play button is muted.
|
||||
*/
|
||||
export function VideoPlaceholder({
|
||||
title,
|
||||
eyebrow,
|
||||
label = 'Coming soon',
|
||||
src,
|
||||
className,
|
||||
}: VideoPlaceholderProps) {
|
||||
const hasVideo = Boolean(src)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const pendingSeek = useRef<number | null>(null)
|
||||
|
||||
// Chapter rows (VideoChapters) dispatch `academy:seek` with a time in seconds.
|
||||
// Start the video if it isn't playing yet, then jump there. We also announce
|
||||
// that a video exists (and answer a chapters-side query) so the chapter rows
|
||||
// only become interactive when there's actually something to seek.
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
const onSeek = (e: Event) => {
|
||||
const time = (e as CustomEvent<{ time: number }>).detail?.time
|
||||
if (typeof time !== 'number') return
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.currentTime = time
|
||||
void video.play()
|
||||
} else {
|
||||
pendingSeek.current = time
|
||||
setPlaying(true)
|
||||
}
|
||||
}
|
||||
const announce = () => window.dispatchEvent(new Event('academy:video-ready'))
|
||||
window.addEventListener('academy:seek', onSeek)
|
||||
window.addEventListener('academy:video-query', announce)
|
||||
announce()
|
||||
return () => {
|
||||
window.removeEventListener('academy:seek', onSeek)
|
||||
window.removeEventListener('academy:video-query', announce)
|
||||
}
|
||||
}, [src])
|
||||
|
||||
if (playing && src) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'not-prose my-6 aspect-video w-full overflow-hidden rounded-[20px] bg-black',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: lesson videos have no caption track yet */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={resolveVideoSrc(src)}
|
||||
title={title ?? 'Lesson video'}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
onLoadedMetadata={() => {
|
||||
if (pendingSeek.current != null && videoRef.current) {
|
||||
videoRef.current.currentTime = pendingSeek.current
|
||||
void videoRef.current.play()
|
||||
pendingSeek.current = null
|
||||
}
|
||||
}}
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'not-prose group relative my-6 aspect-video w-full select-none overflow-hidden rounded-[20px] font-season transition-transform duration-200 [container-type:inline-size]',
|
||||
'shadow-[inset_0_0_0_1px_#E6E6E6] [background:radial-gradient(130%_130%_at_50%_14%,#ffffff_0%,#f6f6f6_55%,#ececec_100%)]',
|
||||
'dark:shadow-none dark:[background:radial-gradient(130%_130%_at_50%_18%,#1c1c1c_0%,#121212_45%,#0a0a0a_100%)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Blueprint grid — faint, fading to atmosphere at the edges */}
|
||||
<div
|
||||
aria-hidden
|
||||
className='pointer-events-none absolute inset-0 [background-image:linear-gradient(rgba(18,18,18,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(18,18,18,0.05)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(120%_90%_at_50%_35%,#000_30%,transparent_100%)] dark:[background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)]'
|
||||
/>
|
||||
|
||||
{/* Corner plus-marks, 20px inset */}
|
||||
{['top-5 left-5', 'top-5 right-5', 'bottom-5 left-5', 'right-5 bottom-5'].map((pos) => (
|
||||
<span
|
||||
key={pos}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute font-mono text-[20px] text-[rgba(18,18,18,0.22)] leading-none dark:text-[rgba(255,255,255,0.28)]',
|
||||
pos
|
||||
)}
|
||||
>
|
||||
+
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Top-right status pill — only until a video is wired up */}
|
||||
{!hasVideo && (
|
||||
<span className='absolute top-6 right-6 z-10 inline-flex items-center gap-2 rounded-full border border-[var(--border-1)] bg-[var(--surface-2)] px-4 py-2 font-medium text-[12px] text-[var(--text-secondary)] uppercase tracking-[0.14em] md:top-8 md:right-8 dark:bg-[var(--surface-1)]'>
|
||||
<span className='size-1.5 rounded-full bg-[var(--brand-accent)]' />
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Heading: eyebrow + title, bottom-left (design: left:40 bottom:40) */}
|
||||
<div className='absolute bottom-10 left-10 z-10 max-w-[80%]'>
|
||||
{eyebrow && (
|
||||
<span className='mb-[14px] block font-normal text-[#5F5F5F] text-[clamp(15px,2cqi,22px)] italic tracking-[-0.01em] dark:text-[#B4B4B4]'>
|
||||
{eyebrow}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<span className='block font-semibold text-[#121212] text-[clamp(2.5rem,9.5cqi,5.5rem)] leading-[0.96] tracking-[-0.035em] dark:text-[#F8F8F8]'>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wordmark, bottom-right (design: right:40 bottom:40, svg height 22) */}
|
||||
<span className='absolute right-10 bottom-10 z-10 text-[#121212] dark:text-white/90'>
|
||||
<SimWordmark className='block h-[22px] w-auto' />
|
||||
</span>
|
||||
|
||||
{/* Centered play button — active when a video is wired, muted otherwise */}
|
||||
<div className='absolute inset-0 z-10 grid place-items-center'>
|
||||
{hasVideo ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label={title ? `Play ${title}` : 'Play video'}
|
||||
className='grid h-12 w-16 cursor-pointer place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] transition-transform duration-200 hover:scale-105 active:scale-95 dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'
|
||||
>
|
||||
<svg
|
||||
width='18'
|
||||
height='20'
|
||||
viewBox='0 0 18 20'
|
||||
aria-hidden
|
||||
className='translate-x-[1px] text-[#121212] dark:text-white'
|
||||
>
|
||||
<path d='M0 0l18 10L0 20z' fill='currentColor' />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className='grid h-12 w-16 place-items-center rounded-[14px] bg-[rgba(255,255,255,0.78)] opacity-60 shadow-[0_1px_3px_rgba(18,18,18,0.12),inset_0_0_0_1px_#E6E6E6] backdrop-blur-[4px] dark:bg-[rgba(10,10,10,0.72)] dark:shadow-none'>
|
||||
<svg
|
||||
width='18'
|
||||
height='20'
|
||||
viewBox='0 0 18 20'
|
||||
aria-hidden
|
||||
className='translate-x-[1px] text-[#121212] dark:text-white'
|
||||
>
|
||||
<path d='M0 0l18 10L0 20z' fill='currentColor' />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn, getAssetUrl } from '@/lib/utils'
|
||||
import { Lightbox } from './lightbox'
|
||||
|
||||
interface VideoProps {
|
||||
src: string
|
||||
className?: string
|
||||
autoPlay?: boolean
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
playsInline?: boolean
|
||||
enableLightbox?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function Video({
|
||||
src,
|
||||
className = 'w-full rounded-xl border border-[var(--border)] overflow-hidden outline-none focus:outline-none',
|
||||
autoPlay = true,
|
||||
loop = true,
|
||||
muted = true,
|
||||
playsInline = true,
|
||||
enableLightbox = true,
|
||||
width,
|
||||
height,
|
||||
}: VideoProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const startTimeRef = useRef(0)
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
const openLightbox = () => {
|
||||
startTimeRef.current = videoRef.current?.currentTime ?? 0
|
||||
setIsLightboxOpen(true)
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay={autoPlay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
className,
|
||||
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-[0.97]'
|
||||
)}
|
||||
src={getAssetUrl(src)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableLightbox ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={openLightbox}
|
||||
aria-label={`Open ${src} in media viewer`}
|
||||
className='group contents'
|
||||
>
|
||||
{video}
|
||||
</button>
|
||||
) : (
|
||||
video
|
||||
)}
|
||||
|
||||
{enableLightbox && (
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={`Video: ${src}`}
|
||||
type='video'
|
||||
startTime={startTimeRef.current}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LearnItem {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
interface WhatYouWillLearnProps {
|
||||
items: LearnItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* "What you will learn" — a flat callout matching the docs' flat/divider
|
||||
* language. A quiet muted label (like the TOC heading) sits above the
|
||||
* takeaways; dividers fall only between items, so the label reads as a marker
|
||||
* rather than an underlined heading and never competes with the item titles.
|
||||
*/
|
||||
export function WhatYouWillLearn({ items, className }: WhatYouWillLearnProps) {
|
||||
return (
|
||||
<div className={cn('not-prose', className)}>
|
||||
<p className='mb-3 font-medium text-[0.8125rem] text-[var(--text-muted)]'>
|
||||
What you will learn
|
||||
</p>
|
||||
<div className='divide-y divide-[var(--border)]'>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className='py-3.5 first:pt-0 last:pb-0'>
|
||||
<p className='mb-1 font-medium text-[var(--text-primary)] text-sm'>{item.title}</p>
|
||||
<p className='m-0 text-[var(--text-secondary)] text-sm leading-relaxed'>{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import type { PreviewWorkflow } from './workflow-data'
|
||||
|
||||
/**
|
||||
* Workflows shown in the academy videos, reproduced block-for-block so each
|
||||
* page's written supplement shows the same machine the video builds. Rows and
|
||||
* operation labels match the videos (which match the block registry).
|
||||
*/
|
||||
|
||||
/** files/intro + files/object — the invoice intake machine. */
|
||||
export const AV_INVOICE_INTAKE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-invoice-intake',
|
||||
name: 'Invoice intake',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmailtrigger',
|
||||
name: 'Gmail Email Trigger',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Include Attachments', value: 'Enabled' }],
|
||||
},
|
||||
{
|
||||
id: 'file',
|
||||
name: 'File',
|
||||
type: 'file',
|
||||
bgColor: '#40916C',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Read' },
|
||||
{ title: 'Files', value: '<gmailtrigger.attachments[0]>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Extract the invoice fields' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Files', value: '<file.files[0]>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supabase',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
position: { x: 1020, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Create a Row' },
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Data', value: '<agent.content>' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'trigger-file', source: 'gmailtrigger', target: 'file' },
|
||||
{ id: 'file-agent', source: 'file', target: 'agent' },
|
||||
{ id: 'agent-supabase', source: 'agent', target: 'supabase' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/block — the Qualify agent the camera rides through. */
|
||||
export const AV_QUALIFY_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-qualify',
|
||||
name: 'Qualify a lead',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [{ title: 'Data', value: '<qualify.content>' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-qualify', source: 'start', target: 'qualify' },
|
||||
{ id: 'qualify-response', source: 'qualify', target: 'response' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/memory — the Support agent with Memory set to Conversation. */
|
||||
export const AV_MEMORY_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-memory',
|
||||
name: 'Support agent with memory',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Customer message' }],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
name: 'Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Memory', value: 'Conversation · user-123' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-support', source: 'start', target: 'support' }],
|
||||
}
|
||||
|
||||
/** tables/operations — the Table block with a filtered query. */
|
||||
export const AV_TABLE_OPS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-table-ops',
|
||||
name: 'Query the tickets table',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Run' }],
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
name: 'Table 1',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Query Rows' },
|
||||
{ title: 'Table', value: 'tickets' },
|
||||
{ title: 'Filter Conditions', value: 'priority equals high' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-table', source: 'start', target: 'table' }],
|
||||
}
|
||||
|
||||
/** agents/tool-calling — the Qualify agent with research tools attached. */
|
||||
export const AV_TOOLS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-tools',
|
||||
name: 'Qualify with tools',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
tools: [
|
||||
{ type: 'exa', name: 'Search', bgColor: '#1F40ED' },
|
||||
{ type: 'github', name: 'GitHub', bgColor: '#181717' },
|
||||
{ type: 'hubspot', name: 'CRM', bgColor: '#FF7A59' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 680, y: 0 },
|
||||
rows: [{ title: 'Data', value: '<qualify.content>' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-qualify', source: 'start', target: 'qualify' },
|
||||
{ id: 'qualify-response', source: 'qualify', target: 'response' },
|
||||
],
|
||||
}
|
||||
|
||||
/** agents/skills — the same agent with a skill attached. */
|
||||
export const AV_SKILLS_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-skills',
|
||||
name: 'Qualify with a skill',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Lead' }],
|
||||
},
|
||||
{
|
||||
id: 'qualify',
|
||||
name: 'Qualify',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Skills', value: 'answer-with-citations' },
|
||||
],
|
||||
tools: [{ type: 'exa', name: 'Search', bgColor: '#1F40ED' }],
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'start-qualify', source: 'start', target: 'qualify' }],
|
||||
}
|
||||
|
||||
/** chat/intro — the support-desk workflow the chat operates. */
|
||||
export const AV_SUPPORT_DESK_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-support-desk',
|
||||
name: 'support-desk',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Ticket' }],
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
name: 'Triage',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 320, y: 60 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
tools: [{ type: 'knowledge', name: 'Help Center', bgColor: '#00B0B0' }],
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Urgent?',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 660, y: 60 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'condition-if', label: 'If', value: '<triage.urgent>' },
|
||||
{ id: 'condition-else', label: 'else' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'escalate',
|
||||
name: 'Escalate',
|
||||
type: 'slack',
|
||||
bgColor: '#611F69',
|
||||
position: { x: 1000, y: -40 },
|
||||
rows: [{ title: 'Channel', value: '#support-urgent' }],
|
||||
},
|
||||
{
|
||||
id: 'reply',
|
||||
name: 'Reply',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 1000, y: 160 },
|
||||
rows: [{ title: 'Messages', value: 'Draft the reply' }],
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Log',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 1340, y: 60 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
{ title: 'Table', value: 'tickets' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-triage', source: 'start', target: 'triage' },
|
||||
{ id: 'triage-condition', source: 'triage', target: 'condition' },
|
||||
{
|
||||
id: 'condition-escalate',
|
||||
source: 'condition',
|
||||
target: 'escalate',
|
||||
sourceHandle: 'condition-if',
|
||||
},
|
||||
{ id: 'condition-reply', source: 'condition', target: 'reply', sourceHandle: 'condition-else' },
|
||||
{ id: 'escalate-log', source: 'escalate', target: 'log' },
|
||||
{ id: 'reply-log', source: 'reply', target: 'log' },
|
||||
],
|
||||
}
|
||||
|
||||
/** chat/building — the content-agent the chat builds: candidates drafted in
|
||||
* parallel, media generated, an evaluator scoring, results kept. */
|
||||
export const AV_CONTENT_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'av-content-agent',
|
||||
name: 'content-agent',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 95 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Idea' }],
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
name: 'Candidates',
|
||||
type: 'parallel',
|
||||
bgColor: '#1D1C1A',
|
||||
position: { x: 320, y: 30 },
|
||||
size: { width: 430, height: 170 },
|
||||
rows: [],
|
||||
},
|
||||
{
|
||||
id: 'writer',
|
||||
name: 'Writer',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 150, y: 62 },
|
||||
parentId: 'candidates',
|
||||
rows: [
|
||||
{ title: 'Messages', value: '<start.input>' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
type: 'evaluator',
|
||||
bgColor: '#8B5CF6',
|
||||
position: { x: 840, y: 95 },
|
||||
rows: [
|
||||
{ title: 'Metrics', value: 'Voice · Hook' },
|
||||
{ title: 'Content', value: '<writer.content>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'scores',
|
||||
name: 'Scores',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 1180, y: 95 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
{ title: 'Table', value: 'scores' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-candidates', source: 'start', target: 'candidates' },
|
||||
{
|
||||
id: 'candidates-writer',
|
||||
source: 'candidates',
|
||||
target: 'writer',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
{ id: 'candidates-evaluator', source: 'candidates', target: 'evaluator' },
|
||||
{ id: 'evaluator-scores', source: 'evaluator', target: 'scores' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { PreviewWorkflow } from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/**
|
||||
* Single-block preview workflows for the block reference heroes — one per block,
|
||||
* authored to match exactly what the builder canvas shows. Source of truth for
|
||||
* `<BlockPreview type="...">`.
|
||||
*
|
||||
* Each entry is a one-block {@link PreviewWorkflow} rendered through the shared
|
||||
* {@link WorkflowBlockView} (via `DocsBlockNode`), so the hero stays pixel-faithful
|
||||
* to the canvas. Authoring notes:
|
||||
*
|
||||
* - `rows` are the visible sub-block rows; use `'-'` for an empty/unset field (the
|
||||
* canvas shows a dash), or a representative value where the field has a default.
|
||||
* - `branches` render one output handle per entry (Condition's if/else-if/else,
|
||||
* Router's routes). The View regenerates handle topology, so the label doubles as
|
||||
* the branch id.
|
||||
* - `hideTargetHandle: true` for triggers (entry points — no input). The View derives
|
||||
* the default target/source handles and the bottom `Error` row from this gate, so
|
||||
* there is no separate `showError`/`hideSourceHandle` flag.
|
||||
* - `bgColor` is the resolved hex; the Agent uses Sim green `#33C482` (`var(--brand)`).
|
||||
*/
|
||||
export const BLOCK_DISPLAY_WORKFLOWS: Record<string, PreviewWorkflow> = {
|
||||
agent: {
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
blocks: [
|
||||
{
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Messages', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Files', value: '-' },
|
||||
{ title: 'Tools', value: '-' },
|
||||
{ title: 'Skills', value: '-' },
|
||||
{ title: 'Memory', value: 'None' },
|
||||
{ title: 'Temperature', value: '0.7' },
|
||||
{ title: 'Response Format', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
api: {
|
||||
id: 'api',
|
||||
name: 'API',
|
||||
blocks: [
|
||||
{
|
||||
id: 'api',
|
||||
name: 'API',
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'URL', value: '-' },
|
||||
{ title: 'Method', value: 'GET' },
|
||||
{ title: 'Query Params', value: '-' },
|
||||
{ title: 'Headers', value: '-' },
|
||||
{ title: 'Body', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
condition: {
|
||||
id: 'condition',
|
||||
name: 'Condition',
|
||||
blocks: [
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Condition',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'if', label: 'if' },
|
||||
{ id: 'else if', label: 'else if' },
|
||||
{ id: 'else', label: 'else' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
credential: {
|
||||
id: 'credential',
|
||||
name: 'Credential',
|
||||
blocks: [
|
||||
{
|
||||
id: 'credential',
|
||||
name: 'Credential',
|
||||
type: 'credential',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Select Credential' },
|
||||
{ title: 'Credential', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
evaluator: {
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
blocks: [
|
||||
{
|
||||
id: 'evaluator',
|
||||
name: 'Evaluator',
|
||||
type: 'evaluator',
|
||||
bgColor: '#4D5FFF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Evaluation Metrics', value: '-' },
|
||||
{ title: 'Content', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
function: {
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
blocks: [
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Function',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
guardrails: {
|
||||
id: 'guardrails',
|
||||
name: 'Guardrails',
|
||||
blocks: [
|
||||
{
|
||||
id: 'guardrails',
|
||||
name: 'Guardrails',
|
||||
type: 'guardrails',
|
||||
bgColor: '#3D642D',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Content to Validate', value: '-' },
|
||||
{ title: 'Validation Type', value: 'Valid JSON' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
response: {
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
blocks: [
|
||||
{
|
||||
id: 'response',
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Response Data Mode', value: 'Builder' },
|
||||
{ title: 'Response Structure', value: '-' },
|
||||
{ title: 'Status Code', value: '-' },
|
||||
{ title: 'Response Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
router: {
|
||||
id: 'router',
|
||||
name: 'Router',
|
||||
blocks: [
|
||||
{
|
||||
id: 'router',
|
||||
name: 'Router',
|
||||
type: 'router',
|
||||
bgColor: '#28C43F',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Context', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
branches: [{ id: 'route 1', label: 'route 1' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
variables: {
|
||||
id: 'variables',
|
||||
name: 'Variables',
|
||||
blocks: [
|
||||
{
|
||||
id: 'variables',
|
||||
name: 'Variables',
|
||||
type: 'variables',
|
||||
bgColor: '#8B5CF6',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [{ title: 'Variable Assignments', value: '-' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
wait: {
|
||||
id: 'wait',
|
||||
name: 'Wait',
|
||||
blocks: [
|
||||
{
|
||||
id: 'wait',
|
||||
name: 'Wait',
|
||||
type: 'wait',
|
||||
bgColor: '#F59E0B',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Wait Amount', value: '10' },
|
||||
{ title: 'Unit', value: 'Seconds' },
|
||||
{ title: 'Async', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
webhook: {
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
blocks: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Payload', value: '-' },
|
||||
{ title: 'Signing Secret', value: '-' },
|
||||
{ title: 'Additional Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
workflow: {
|
||||
id: 'workflow',
|
||||
name: 'Workflow',
|
||||
blocks: [
|
||||
{
|
||||
id: 'workflow',
|
||||
name: 'Workflow',
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Select Workflow', value: '-' },
|
||||
{ title: 'Input Variable', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
human_in_the_loop: {
|
||||
id: 'human_in_the_loop',
|
||||
name: 'Human in the Loop',
|
||||
blocks: [
|
||||
{
|
||||
id: 'human_in_the_loop',
|
||||
name: 'Human in the Loop',
|
||||
type: 'human_in_the_loop',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
rows: [
|
||||
{ title: 'Display Data', value: '-' },
|
||||
{ title: 'Notification (Send URL)', value: '-' },
|
||||
{ title: 'Resume Form', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
schedule: {
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Run frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '-' },
|
||||
{ title: 'Timezone', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
rss: {
|
||||
id: 'rss',
|
||||
name: 'RSS Feed',
|
||||
blocks: [
|
||||
{
|
||||
id: 'rss',
|
||||
name: 'RSS Feed',
|
||||
type: 'rss',
|
||||
bgColor: '#F97316',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Feed URL', value: '-' }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
webhook_trigger: {
|
||||
id: 'webhook_trigger',
|
||||
name: 'Webhook',
|
||||
blocks: [
|
||||
{
|
||||
id: 'webhook_trigger',
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Require Authentication', value: 'On' },
|
||||
{ title: 'Authentication Token', value: '-' },
|
||||
{ title: 'Secret Header Name (Optional)', value: '-' },
|
||||
{ title: 'Deduplication Field (Optional)', value: '-' },
|
||||
{ title: 'Acknowledgement', value: 'Default' },
|
||||
{ title: 'Verify Test Events', value: '-' },
|
||||
{ title: 'Input Format', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
table: {
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
blocks: [
|
||||
{
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Event type', value: 'Row updated' },
|
||||
{ title: 'Watch columns', value: '-' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { Clock, Database, Layers, Repeat, Table } from 'lucide-react'
|
||||
import {
|
||||
ApiIcon,
|
||||
ChartBarIcon,
|
||||
CodeIcon,
|
||||
ConditionalIcon,
|
||||
ConnectIcon,
|
||||
CredentialIcon,
|
||||
HumanInTheLoopIcon,
|
||||
ResponseIcon,
|
||||
RssIcon,
|
||||
ScheduleIcon,
|
||||
ShieldCheckIcon,
|
||||
VariableIcon,
|
||||
WebhookIcon,
|
||||
WorkflowIcon,
|
||||
} from '@/components/icons'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
|
||||
/**
|
||||
* The two Sim-specific block glyphs we need, ported verbatim from
|
||||
* `apps/sim/components/icons.tsx` so the preview matches the real builder.
|
||||
* Other block types fall back to lucide-react stand-ins for now.
|
||||
*/
|
||||
export function StartIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='26'
|
||||
height='16'
|
||||
viewBox='0 0 26 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M7.8 13C9.23 13 10.45 12.49 11.47 11.47C12.49 10.45 13 9.23 13 7.8C13 6.37 12.49 5.15 11.47 4.13C10.45 3.11 9.23 2.6 7.8 2.6C6.37 2.6 5.15 3.11 4.13 4.13C3.11 5.15 2.6 6.37 2.6 7.8C2.6 9.23 3.11 10.45 4.13 11.47C5.15 12.49 6.37 13 7.8 13ZM7.8 15.6C5.63 15.6 3.79 14.84 2.28 13.33C0.76 11.81 0 9.97 0 7.8C0 5.63 0.76 3.79 2.28 2.28C3.79 0.76 5.63 0 7.8 0C9.75 0 11.45 0.62 12.89 1.85C14.33 3.09 15.2 4.64 15.5 6.5H24.7C25.07 6.5 25.38 6.62 25.63 6.87C25.88 7.12 26 7.43 26 7.8C26 8.17 25.87 8.48 25.63 8.73C25.38 8.98 25.07 9.1 24.7 9.1H15.5C15.2 10.96 14.33 12.51 12.89 13.75C11.44 14.98 9.75 15.6 7.8 15.6Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='21'
|
||||
height='24'
|
||||
viewBox='0 0 21 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15.67 9.25H4.67C2.64 9.25 1 10.89 1 12.92V18.42C1 20.44 2.64 22.08 4.67 22.08H15.67C17.69 22.08 19.33 20.44 19.33 18.42V12.92C19.33 10.89 17.69 9.25 15.67 9.25Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M10.17 5.58C11.18 5.58 12 4.76 12 3.75C12 2.74 11.18 1.92 10.17 1.92C9.15 1.92 8.33 2.74 8.33 3.75C8.33 4.76 9.15 5.58 10.17 5.58Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M10.17 5.59V9.25M7.42 16.59V14.75M12.92 14.75V16.59'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Block type → glyph. Brand glyphs from the app for core blocks; lucide stand-ins for the rest. */
|
||||
export const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
starter: StartIcon,
|
||||
start_trigger: StartIcon,
|
||||
agent: AgentIcon,
|
||||
api: ApiIcon,
|
||||
condition: ConditionalIcon,
|
||||
credential: CredentialIcon,
|
||||
evaluator: ChartBarIcon,
|
||||
function: CodeIcon,
|
||||
guardrails: ShieldCheckIcon,
|
||||
human_in_the_loop: HumanInTheLoopIcon,
|
||||
response: ResponseIcon,
|
||||
router: ConnectIcon,
|
||||
variables: VariableIcon,
|
||||
wait: Clock,
|
||||
webhook: WebhookIcon,
|
||||
workflow: WorkflowIcon,
|
||||
schedule: ScheduleIcon,
|
||||
rss: RssIcon,
|
||||
loop: Repeat,
|
||||
parallel: Layers,
|
||||
knowledge_base: Database,
|
||||
knowledge: Database,
|
||||
table: Table,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a block (or tool) type to its glyph: the core-block map first, then
|
||||
* the integration icon map so diagrams can render tool chips too. Returns
|
||||
* `null` when no glyph is registered.
|
||||
*/
|
||||
export function resolveIcon(type: string): ComponentType<{ className?: string }> | null {
|
||||
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChipSelect,
|
||||
ChipSwitch,
|
||||
ChipTag,
|
||||
chipFieldSurfaceClass,
|
||||
chipFieldTextClass,
|
||||
cn,
|
||||
FieldDivider,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { BookOpen, Pencil } from 'lucide-react'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
import { formatReferences } from '@/components/workflow-preview/format-references'
|
||||
|
||||
type FieldKind = 'select' | 'input' | 'textarea' | 'code' | 'slider' | 'toggle'
|
||||
|
||||
interface InspectorField {
|
||||
label: string
|
||||
required?: boolean
|
||||
kind?: FieldKind
|
||||
/** Shown inside the control. For 'toggle', "on"/"off". For 'slider', the number. */
|
||||
value?: string
|
||||
/** Muted placeholder when there's no value. */
|
||||
placeholder?: string
|
||||
/** Slider fill, 0–100. */
|
||||
percent?: number
|
||||
}
|
||||
|
||||
interface InspectorTool {
|
||||
type: string
|
||||
name: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
interface BlockInspectorProps {
|
||||
/** Block name in the header, e.g. "Agent 1". */
|
||||
name: string
|
||||
/** Block type, for the header icon. */
|
||||
type?: string
|
||||
color?: string
|
||||
fields: InspectorField[]
|
||||
tools?: InspectorTool[]
|
||||
/** Render as a borderless panel filling its parent (the lightbox sidebar). */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
/**
|
||||
* Read-only facsimile of one configuration field, composed from emcn chip
|
||||
* chrome: `select`→{@link ChipSelect}, `toggle`→{@link ChipSwitch}; text fields
|
||||
* (`input`/`textarea`/`code`) render the value with `<...>`/`{{...}}` references
|
||||
* highlighted via {@link formatReferences} in the canonical chip field surface.
|
||||
* `slider` has no chip equivalent and stays a minimal app-token bar.
|
||||
*/
|
||||
function FieldControl({ field }: { field: InspectorField }) {
|
||||
const kind = field.kind ?? 'input'
|
||||
const value = field.value ?? ''
|
||||
const placeholder = field.placeholder ?? '—'
|
||||
|
||||
if (kind === 'select') {
|
||||
return (
|
||||
<ChipSelect
|
||||
fullWidth
|
||||
value={value || undefined}
|
||||
onChange={NOOP}
|
||||
placeholder={placeholder}
|
||||
options={value ? [{ value, label: value }] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'toggle') {
|
||||
const on = field.value === 'on'
|
||||
return (
|
||||
<ChipSwitch
|
||||
value={on ? 'on' : 'off'}
|
||||
onChange={NOOP}
|
||||
aria-label={field.label}
|
||||
options={[
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'slider') {
|
||||
const percent = field.percent ?? 50
|
||||
return (
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<div className='relative h-[4px] flex-1 rounded-full bg-[var(--surface-5)]'>
|
||||
<div
|
||||
className='absolute inset-y-0 left-0 rounded-full bg-[var(--brand-secondary)]'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 size-[12px] rounded-full border border-[var(--border-1)] bg-white'
|
||||
style={{ left: `calc(${percent}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{field.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// input / textarea / code: read-only value with `<...>` block references and
|
||||
// `{{...}}` environment variables highlighted, in the canonical chip chrome.
|
||||
const content = value ? (
|
||||
formatReferences(value)
|
||||
) : (
|
||||
<span className='text-[var(--text-muted)]'>{placeholder}</span>
|
||||
)
|
||||
if (kind === 'textarea' || kind === 'code') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
chipFieldSurfaceClass,
|
||||
chipFieldTextClass,
|
||||
'min-h-[60px] whitespace-pre-wrap break-words px-2 py-1.5',
|
||||
kind === 'code' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn(chipFieldSurfaceClass, 'flex h-[30px] items-center px-2')}>
|
||||
<span className={cn(chipFieldTextClass, 'min-w-0 truncate')}>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorFieldRow({ field }: { field: InspectorField }) {
|
||||
return (
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<Label className='pl-0.5'>
|
||||
{field.label}
|
||||
{field.required && <span className='ml-0.5'>*</span>}
|
||||
</Label>
|
||||
<FieldControl field={field} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only facsimile of the editor's right-hand block inspector: the block
|
||||
* header, its configuration fields as static chip controls, and its
|
||||
* connections. Hand-authored per usage, like {@link WorkflowPreview} examples.
|
||||
*/
|
||||
export function BlockInspector({
|
||||
name,
|
||||
type = 'agent',
|
||||
color = '#33C482',
|
||||
fields,
|
||||
tools,
|
||||
embedded = false,
|
||||
}: BlockInspectorProps) {
|
||||
const Icon = resolveIcon(type)
|
||||
const hasTools = Boolean(tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[var(--surface-1)]',
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-y-auto'
|
||||
: 'not-prose my-6 w-full max-w-[380px] overflow-hidden rounded-xl border border-[var(--border)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-3 py-1.5'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<div
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{Icon && <Icon className='size-[12px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-sm'>{name}</span>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Pencil className='size-[14px]' />
|
||||
<BookOpen className='size-[14px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col px-3 py-3'>
|
||||
{fields.map((field, i) => (
|
||||
<div key={field.label}>
|
||||
{i > 0 && <FieldDivider />}
|
||||
<InspectorFieldRow field={field} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasTools && (
|
||||
<div>
|
||||
{fields.length > 0 && <FieldDivider />}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
<Label className='pl-0.5'>Tools</Label>
|
||||
<div className='flex flex-wrap gap-[6px]'>
|
||||
{tools?.map((tool) => {
|
||||
const TIcon = resolveIcon(tool.type)
|
||||
return (
|
||||
<ChipTag key={tool.type} variant='gray'>
|
||||
<span
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{TIcon && <TIcon className='size-[9px] text-white' />}
|
||||
</span>
|
||||
{tool.name}
|
||||
</ChipTag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { domAnimation, LazyMotion } from 'framer-motion'
|
||||
import ReactFlow, { type NodeTypes, ReactFlowProvider } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
|
||||
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
|
||||
import { toReactFlowElements } from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/** The hero mounts the same node type the canvas uses, so it can never drift. */
|
||||
const NODE_TYPES: NodeTypes = { previewBlock: DocsBlockNode }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
/** `maxZoom` mirrors the previous hand-rolled hero's 1.3 scale. */
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1.3 } as const
|
||||
|
||||
interface BlockPreviewProps {
|
||||
/** Block key from {@link BLOCK_DISPLAY_WORKFLOWS} (e.g. `agent`, `condition`, `webhook_trigger`). */
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single block exactly as it appears on the builder canvas, drawn by the
|
||||
* shared {@link WorkflowBlockView} (via `DocsBlockNode`) through the same ReactFlow
|
||||
* machinery as the multi-block diagrams — never a parallel hand-rolled card. Static
|
||||
* and non-interactive (no pan/zoom), centered in a bordered container. Use as the hero
|
||||
* on a block reference page: `<BlockPreview type="agent" />`. Edit the source data in
|
||||
* `block-display-workflows.ts`.
|
||||
*/
|
||||
export function BlockPreview({ type }: BlockPreviewProps) {
|
||||
const workflow = BLOCK_DISPLAY_WORKFLOWS[type]
|
||||
|
||||
const elements = useMemo(() => (workflow ? toReactFlowElements(workflow) : null), [workflow])
|
||||
|
||||
if (!workflow || !elements) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='not-prose my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
style={{ height: 400 }}
|
||||
>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={elements.nodes}
|
||||
edges={elements.edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
proOptions={PRO_OPTIONS}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW_OPTIONS}
|
||||
minZoom={0.2}
|
||||
maxZoom={1.3}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag={false}
|
||||
panOnScroll={false}
|
||||
preventScrolling={false}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentType, memo } from 'react'
|
||||
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
|
||||
import { m } from 'framer-motion'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/** Renders the colored square with no glyph when a block type has no registered icon. */
|
||||
const EMPTY_ICON: ComponentType<{ className?: string }> = () => null
|
||||
|
||||
const RING_STYLES = 'ring-[1.75px] ring-[var(--brand-secondary)]'
|
||||
|
||||
interface DocsBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
tools?: PreviewTool[]
|
||||
hideTargetHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
isHighlighted?: boolean
|
||||
isDimmed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs adapter for workflow block nodes: maps the static preview data to the
|
||||
* shared {@link WorkflowBlockView}'s props. Carries no stores, hooks, or
|
||||
* queries — it only reshapes data into View props and wraps the result in the
|
||||
* dim/stagger motion used by the rest of the diagram (the parent
|
||||
* `WorkflowPreview` provides the `LazyMotion` feature set). The block's ring is
|
||||
* driven by `hasRing`/`ringStyles` inside the View.
|
||||
*/
|
||||
export const DocsBlockNode = memo(function DocsBlockNode({ id, data }: NodeProps<DocsBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows: dataRows,
|
||||
branches,
|
||||
tools,
|
||||
hideTargetHandle = false,
|
||||
index = 0,
|
||||
animate = false,
|
||||
isHighlighted = false,
|
||||
isDimmed = false,
|
||||
} = data
|
||||
|
||||
/** The View gates router handle topology on `type === 'router_v2'`. */
|
||||
const type = blockType === 'router' ? 'router_v2' : blockType
|
||||
|
||||
const Icon = resolveIcon(blockType) ?? EMPTY_ICON
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
|
||||
const hasBranches = Boolean(branches && branches.length > 0)
|
||||
const hasTools = Boolean(tools && tools.length > 0)
|
||||
|
||||
/** The View renders the default target/source/error handles (and the error row) for non-trigger blocks; mirror that gate. */
|
||||
const shouldShowDefaultHandles = !hideTargetHandle
|
||||
const hasContentBelowHeader =
|
||||
dataRows.length > 0 || hasBranches || hasTools || shouldShowDefaultHandles
|
||||
|
||||
/**
|
||||
* Strip the app's `condition-`/`router-` handle prefixes — the View
|
||||
* regenerates them, so passing them through would double-prefix the handle id.
|
||||
* Branch + router-context values render through the editor's `getDisplayValue`,
|
||||
* which shows `-` for a blank value (e.g. an `else` branch); mirror that.
|
||||
*/
|
||||
const conditionRows =
|
||||
type === 'condition'
|
||||
? (branches ?? []).map((branch) => ({
|
||||
id: branch.id.replace(/^condition-/, ''),
|
||||
title: branch.label,
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
: []
|
||||
const routerRows =
|
||||
type === 'router_v2'
|
||||
? (branches ?? []).map((branch) => ({
|
||||
id: branch.id.replace(/^router-/, ''),
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
: []
|
||||
/** The View renders the router's leading Context row from this prop, not `rows`. */
|
||||
const routerContextValue =
|
||||
type === 'router_v2' ? dataRows.find((row) => row.title === 'Context')?.value || '-' : undefined
|
||||
|
||||
/**
|
||||
* Non-branch content only — the View renders condition/router/error rows from
|
||||
* the conditionRows/routerRows it receives, so their order stays locked to its
|
||||
* handle geometry in one place.
|
||||
*/
|
||||
const rows =
|
||||
type === 'condition' || type === 'router_v2' ? null : (
|
||||
<>
|
||||
{dataRows.map((row) => (
|
||||
<SubBlockRowView key={row.title} title={row.title} displayValue={row.value} />
|
||||
))}
|
||||
{hasTools && (
|
||||
<SubBlockRowView
|
||||
title='Tools'
|
||||
displayValue={tools?.map((tool) => tool.name).join(', ')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className='relative transition-opacity duration-300'
|
||||
style={{ opacity: isDimmed ? 0.35 : 1 }}
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: isDimmed ? 0.35 : 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<WorkflowBlockView
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
isEnabled
|
||||
isLocked={false}
|
||||
hasRing={Boolean(isHighlighted)}
|
||||
ringStyles={RING_STYLES}
|
||||
Icon={Icon}
|
||||
iconBgColor={bgColor}
|
||||
horizontalHandles
|
||||
shouldShowDefaultHandles={shouldShowDefaultHandles}
|
||||
hasContentBelowHeader={hasContentBelowHeader}
|
||||
conditionRows={conditionRows}
|
||||
routerRows={routerRows}
|
||||
routerContextValue={routerContextValue}
|
||||
wouldCreateConnectionCycle={() => false}
|
||||
onSelect={() => {}}
|
||||
rows={rows}
|
||||
/>
|
||||
</m.div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
|
||||
interface DocsContainerData {
|
||||
name: string
|
||||
blockType: string
|
||||
size?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs adapter for loop/parallel container blocks: maps the static preview data
|
||||
* to {@link SubflowNodeView}'s read-only `isPreview` shape. Carries no stores,
|
||||
* hooks, or queries — it only reshapes data into View props.
|
||||
*/
|
||||
export const DocsContainerNode = memo(function DocsContainerNode({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<DocsContainerData>) {
|
||||
const subflowData: SubflowNodeData = {
|
||||
kind: data.blockType === 'parallel' ? 'parallel' : 'loop',
|
||||
name: data.name,
|
||||
width: data.size?.width,
|
||||
height: data.size?.height,
|
||||
isPreview: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<SubflowNodeView
|
||||
id={id}
|
||||
data={subflowData}
|
||||
isEnabled
|
||||
isLocked={false}
|
||||
isFocused={false}
|
||||
nestingLevel={0}
|
||||
canEditWorkflow={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Block references `<block.field>` and environment variables `{{VAR}}`. */
|
||||
const REFERENCE_PATTERN = /(<[^<>]+>|\{\{[^{}]+\}\})/g
|
||||
|
||||
/**
|
||||
* Highlights `<...>` block references and `{{...}}` environment variables in
|
||||
* brand-secondary, mirroring the editor's `formatDisplayText`. Read-only and
|
||||
* static — no validation or tag interactivity, since docs has no workflow state.
|
||||
*/
|
||||
export function formatReferences(text: string): ReactNode[] {
|
||||
if (!text) return []
|
||||
return text.split(REFERENCE_PATTERN).map((part, index) => {
|
||||
if (!part) return null
|
||||
const isReference =
|
||||
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
|
||||
return isReference ? (
|
||||
<span key={index} className='text-[var(--brand-secondary)]'>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export { BlockPreview } from '@/components/workflow-preview/block-preview'
|
||||
export {
|
||||
API_FETCH_WORKFLOW,
|
||||
BUILD_AGENT_WORKFLOW,
|
||||
CLASSIFY_REPLY_WORKFLOW,
|
||||
CLASSIFY_WORKFLOW,
|
||||
COMBINATION_WORKFLOW,
|
||||
CONCURRENCY_WORKFLOW,
|
||||
CONDITION_MODERATE_WORKFLOW,
|
||||
CONDITION_ONBOARD_WORKFLOW,
|
||||
CONDITION_ROUTE_WORKFLOW,
|
||||
CREDENTIAL_ROUTE_WORKFLOW,
|
||||
CREDENTIAL_SHARE_WORKFLOW,
|
||||
ERROR_PATH_WORKFLOW,
|
||||
EVALUATOR_GATE_WORKFLOW,
|
||||
FILE_SUMMARY_WORKFLOW,
|
||||
FUNCTION_RESHAPE_WORKFLOW,
|
||||
FUNCTION_VALIDATE_WORKFLOW,
|
||||
GUARDRAILS_HALLUCINATION_WORKFLOW,
|
||||
GUARDRAILS_JSON_WORKFLOW,
|
||||
GUARDRAILS_PII_WORKFLOW,
|
||||
HITL_APPROVAL_WORKFLOW,
|
||||
HITL_MULTISTAGE_WORKFLOW,
|
||||
HITL_VALIDATE_WORKFLOW,
|
||||
LEAD_SCORER_WORKFLOW,
|
||||
LOOP_WORKFLOW,
|
||||
PARALLEL_WORKFLOW,
|
||||
RESPONSE_API_WORKFLOW,
|
||||
RESPONSE_ERROR_WORKFLOW,
|
||||
RESPONSE_WEBHOOK_WORKFLOW,
|
||||
ROUTER_CLASSIFY_WORKFLOW,
|
||||
ROUTER_LEAD_WORKFLOW,
|
||||
ROUTER_TRIAGE_WORKFLOW,
|
||||
ROUTING_WORKFLOW,
|
||||
SUPPORT_KB_WORKFLOW,
|
||||
TABLE_ENRICH_WORKFLOW,
|
||||
TABLE_ROUNDTRIP_WORKFLOW,
|
||||
VARIABLES_CONFIG_WORKFLOW,
|
||||
VARIABLES_RETRY_WORKFLOW,
|
||||
WAIT_FOLLOWUP_WORKFLOW,
|
||||
WAIT_RATELIMIT_WORKFLOW,
|
||||
WEBHOOK_NOTIFY_WORKFLOW,
|
||||
WEBHOOK_TRIGGER_WORKFLOW,
|
||||
WORKFLOW_CALL_WORKFLOW,
|
||||
} from '@/components/workflow-preview/examples'
|
||||
export { OutputBundle } from '@/components/workflow-preview/output-bundle'
|
||||
export type {
|
||||
PreviewBlock,
|
||||
PreviewTool,
|
||||
PreviewWorkflow,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
export { WorkflowPreview } from '@/components/workflow-preview/workflow-preview'
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@sim/emcn'
|
||||
import { ChevronDown, Clipboard, Download, Search } from 'lucide-react'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
|
||||
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
|
||||
|
||||
/** Output value type → emcn Badge color variant. */
|
||||
const TYPE_VARIANT = {
|
||||
string: 'green',
|
||||
number: 'blue',
|
||||
boolean: 'orange',
|
||||
array: 'purple',
|
||||
object: 'gray',
|
||||
null: 'gray',
|
||||
} as const
|
||||
|
||||
interface OutputNode {
|
||||
key: string
|
||||
type?: ValueType
|
||||
/** Primitive value shown beneath the key when expanded. */
|
||||
value?: string
|
||||
/** Nested fields for object/array nodes. */
|
||||
children?: OutputNode[]
|
||||
/** Collapse the node (chevron points right, nothing rendered beneath). */
|
||||
expanded?: boolean
|
||||
/** Emphasize this row as the one being read by the tag below. */
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
interface LogRow {
|
||||
name: string
|
||||
type?: string
|
||||
color?: string
|
||||
duration?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface OutputBundleProps {
|
||||
/** The block's name (unique within the workflow), e.g. "classify". */
|
||||
blockName: string
|
||||
blockType?: string
|
||||
blockColor?: string
|
||||
/** Duration shown on the selected log row. */
|
||||
duration?: string
|
||||
/** Override the Logs column; defaults to Start + this block (selected). */
|
||||
logs?: LogRow[]
|
||||
values: OutputNode[]
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ValueType }) {
|
||||
return (
|
||||
<Badge variant={TYPE_VARIANT[type]} size='sm'>
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth = 0 }: { node: OutputNode; depth?: number }) {
|
||||
const type = node.type ?? 'string'
|
||||
const expanded = node.expanded ?? Boolean(node.children || node.value !== undefined)
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<div className='flex min-h-[26px] items-center gap-2 rounded-[6px] px-1'>
|
||||
<span
|
||||
className='text-[13px]'
|
||||
style={{ color: node.highlight ? 'var(--brand-secondary)' : 'var(--text-primary)' }}
|
||||
>
|
||||
{node.key}
|
||||
</span>
|
||||
<TypeBadge type={type} />
|
||||
<ChevronDown
|
||||
className='h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)]'
|
||||
style={expanded ? undefined : { transform: 'rotate(-90deg)' }}
|
||||
/>
|
||||
</div>
|
||||
{expanded && (node.children || node.value !== undefined) && (
|
||||
<div className='mt-0.5 ml-[5px] flex min-w-0 flex-col gap-0.5 border-[var(--divider)] border-l pl-[10px]'>
|
||||
{node.children
|
||||
? node.children.map((child) => (
|
||||
<TreeNode key={child.key} node={child} depth={depth + 1} />
|
||||
))
|
||||
: node.value !== undefined && (
|
||||
<div className='py-0.5 text-[13px] text-[var(--text-secondary)]'>{node.value}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A miniature of the app's run inspector — the Logs list beside the Output
|
||||
* panel's typed tree — teaching what a block's output is: named, typed values
|
||||
* remembered under the block's name, read with a `<blockName.key>` tag.
|
||||
*/
|
||||
export function OutputBundle({
|
||||
blockName,
|
||||
blockType = 'agent',
|
||||
blockColor = '#33C482',
|
||||
duration = '1.2s',
|
||||
logs,
|
||||
values,
|
||||
}: OutputBundleProps) {
|
||||
const logRows: LogRow[] = logs ?? [
|
||||
{ name: 'Start', type: 'start_trigger', color: '#2FB3FF', duration: '9ms' },
|
||||
{ name: blockName, type: blockType, color: blockColor, duration, selected: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='not-prose my-6 flex w-full max-w-[640px] flex-col gap-3'>
|
||||
<div className='flex overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='flex w-[210px] flex-shrink-0 flex-col border-[var(--border)] border-r px-2 py-2'>
|
||||
<div className='px-2 pb-2 text-[12px] text-[var(--text-muted)]'>Logs</div>
|
||||
{logRows.map((row) => {
|
||||
const Icon = row.type ? resolveIcon(row.type) : null
|
||||
return (
|
||||
<div
|
||||
key={row.name}
|
||||
className='flex h-[30px] items-center gap-2 rounded-[6px] px-2'
|
||||
style={row.selected ? { background: 'var(--surface-active)' } : undefined}
|
||||
>
|
||||
<div
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-[5px]'
|
||||
style={{ background: row.color ?? 'var(--border-1)' }}
|
||||
>
|
||||
{Icon && <Icon className='size-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)]'>{row.name}</span>
|
||||
{row.duration && (
|
||||
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>
|
||||
{row.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col px-3 py-2'>
|
||||
<div className='flex items-center gap-3 pb-2'>
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>Output</span>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>Input</span>
|
||||
<span className='ml-auto flex items-center gap-2 text-[var(--text-subtle)]'>
|
||||
<Search className='size-[12px]' />
|
||||
<Clipboard className='size-[12px]' />
|
||||
<Download className='size-[12px]' />
|
||||
<ChevronDown className='size-[12px]' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
{values.map((node) => (
|
||||
<TreeNode key={node.key} node={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type Edge, type Node, Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on a block (e.g. an Agent's attached tools).
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single block in a preview workflow. Presentational shape — authored by hand
|
||||
* for docs diagrams, not the app's full serialized block state.
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
/**
|
||||
* Branch rows, each with its own right-edge source handle whose id is the
|
||||
* branch id. Author ids in the app's own handle scheme so edges match the
|
||||
* real workflow representation verbatim: `condition-<id>` on Condition
|
||||
* blocks, `router-<routeId>` on Routers.
|
||||
*/
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
tools?: PreviewTool[]
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
/** When set, the block renders as a Loop/Parallel container sized to hold its children. */
|
||||
size?: { width: number; height: number }
|
||||
/** Id of the container block this block sits inside. Its position is relative to the container. */
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow rendered as a read-only, app-styled diagram in the docs.
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string }>
|
||||
}
|
||||
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
const EDGE_STYLE = { stroke: 'var(--workflow-edge)', strokeWidth: 2 } as const
|
||||
const EDGE_STYLE_HIGHLIGHT = { stroke: 'var(--brand-secondary)', strokeWidth: 2.5 } as const
|
||||
/** Edges leaving a block's error port render red, matching the editor. */
|
||||
const EDGE_STYLE_ERROR = { stroke: 'var(--text-error)', strokeWidth: 2 } as const
|
||||
|
||||
/** Optional emphasis: light one block or one edge and dim everything else. */
|
||||
export interface HighlightOptions {
|
||||
highlightBlock?: string
|
||||
highlightEdge?: string
|
||||
/** Ring one block (selection) without dimming the rest. */
|
||||
selectedBlock?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data carries stagger metadata
|
||||
* @param highlight - Optional block/edge to emphasize (dims the rest)
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false,
|
||||
highlight: HighlightOptions = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const { highlightBlock, highlightEdge, selectedBlock } = highlight
|
||||
const hasHighlight = Boolean(highlightBlock || highlightEdge)
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const blocksById = new Map(workflow.blocks.map((b) => [b.id, b]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => {
|
||||
const isContainer = Boolean(block.size)
|
||||
// Nested blocks are authored relative to their container; render them at
|
||||
// absolute coordinates (not React Flow parentNode children) so the edges
|
||||
// between a container and its nested blocks render reliably and on top.
|
||||
const parent = block.parentId ? blocksById.get(block.parentId) : undefined
|
||||
const position = parent
|
||||
? { x: parent.position.x + block.position.x, y: parent.position.y + block.position.y }
|
||||
: block.position
|
||||
return {
|
||||
id: block.id,
|
||||
type: isContainer ? 'previewContainer' : 'previewBlock',
|
||||
position,
|
||||
zIndex: isContainer ? 0 : 1,
|
||||
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
branches: block.branches,
|
||||
tools: block.tools,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
size: block.size,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
|
||||
isDimmed: hasHighlight && highlightBlock !== block.id,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: Edge[] = workflow.edges.map((e) => {
|
||||
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||
const isEdgeHighlight = highlightEdge === e.id
|
||||
const dimmed = hasHighlight && !isEdgeHighlight
|
||||
const isErrorEdge = e.sourceHandle === 'error'
|
||||
// Subflow containers expose a right-edge output handle (`loop-end-source` /
|
||||
// `parallel-end-source`) and a left-edge input handle with no id; regular
|
||||
// blocks use `source` / `target`. Resolve each end to the block's real handle
|
||||
// so edges into and out of Loop/Parallel containers still connect.
|
||||
const sourceBlock = blocksById.get(e.source)
|
||||
const targetBlock = blocksById.get(e.target)
|
||||
const sourceHandle =
|
||||
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
|
||||
const targetHandle = targetBlock?.size ? undefined : 'target'
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: {
|
||||
...(isEdgeHighlight ? EDGE_STYLE_HIGHLIGHT : isErrorEdge ? EDGE_STYLE_ERROR : EDGE_STYLE),
|
||||
opacity: dimmed ? 0.35 : 1,
|
||||
},
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Maximize2, X } from 'lucide-react'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
getSmoothStepPath,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
ReactFlowProvider,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
|
||||
import { BlockInspector } from '@/components/workflow-preview/block-inspector'
|
||||
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
|
||||
import { DocsContainerNode } from '@/components/workflow-preview/docs-container-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewBlock,
|
||||
type PreviewWorkflow,
|
||||
toReactFlowElements,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflow: PreviewWorkflow
|
||||
/** Canvas height in px. Default 260. */
|
||||
height?: number
|
||||
animate?: boolean
|
||||
/** Emphasize one block by id, dimming the rest. */
|
||||
highlightBlock?: string
|
||||
/** Emphasize one edge by id, dimming the rest. */
|
||||
highlightEdge?: string
|
||||
}
|
||||
|
||||
/** Smooth-step edge, matching the app's connection styling. */
|
||||
function PreviewEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 30,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<m.path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
|
||||
opacity: { duration: 0.15, delay: data.delay ?? 0 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
id={id}
|
||||
className='react-flow__edge-path'
|
||||
d={edgePath}
|
||||
style={{ ...style, fill: 'none' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
previewBlock: DocsBlockNode,
|
||||
previewContainer: DocsContainerNode,
|
||||
}
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.25, maxZoom: 1 } as const
|
||||
const LIGHTBOX_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1.4 } as const
|
||||
|
||||
/** Field titles rendered as multiline text in the inspector. */
|
||||
const TEXTAREA_TITLES = new Set(['Messages', 'Prompt', 'Code', 'Data', 'Body', 'Display'])
|
||||
/** Field titles rendered as dropdowns in the inspector. */
|
||||
const SELECT_TITLES = new Set([
|
||||
'Model',
|
||||
'Operation',
|
||||
'Method',
|
||||
'Unit',
|
||||
'Event type',
|
||||
'Validation',
|
||||
'Account',
|
||||
'Table',
|
||||
'Knowledge Base',
|
||||
'Language',
|
||||
'Workflow',
|
||||
'Format',
|
||||
])
|
||||
|
||||
function inspectorFieldsFor(block: PreviewBlock) {
|
||||
// Show the block type's full field list (from the reference data) with this
|
||||
// block's example values overlaid, so the inspector reads like the editor's
|
||||
// panel — every field — not just the summary rows shown on the canvas node.
|
||||
// Apply the template only when the block authored rows that are actually a
|
||||
// subset of it; otherwise the type string is ambiguous (a `table` action vs
|
||||
// the table trigger, a `webhook` trigger vs the webhook action) and the wrong
|
||||
// template would win, so the block's own authored rows are the source of
|
||||
// truth. A block with no rows (e.g. a router defined only by its branches)
|
||||
// keeps its empty set rather than inheriting the template's invented defaults.
|
||||
const exampleByTitle = new Map(block.rows.map((row) => [row.title, row.value]))
|
||||
const template = BLOCK_DISPLAY_WORKFLOWS[block.type]?.blocks[0]?.rows
|
||||
const fullRows =
|
||||
template &&
|
||||
block.rows.length > 0 &&
|
||||
block.rows.every((row) => template.some((field) => field.title === row.title))
|
||||
? template
|
||||
: block.rows
|
||||
const rowFields = fullRows.map((row) => {
|
||||
const value = exampleByTitle.get(row.title) ?? row.value
|
||||
return {
|
||||
label: row.title,
|
||||
kind:
|
||||
TEXTAREA_TITLES.has(row.title) || value.length > 40
|
||||
? ('textarea' as const)
|
||||
: SELECT_TITLES.has(row.title)
|
||||
? ('select' as const)
|
||||
: ('input' as const),
|
||||
value,
|
||||
}
|
||||
})
|
||||
const branchFields = (block.branches ?? []).map((branch) => ({
|
||||
label: branch.label,
|
||||
kind: 'code' as const,
|
||||
// Match the canvas + the editor's getDisplayValue: a blank value reads as '-'.
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
return [...rowFields, ...branchFields]
|
||||
}
|
||||
|
||||
function PreviewFlow({
|
||||
workflow,
|
||||
animate = false,
|
||||
highlightBlock,
|
||||
highlightEdge,
|
||||
selectedBlock,
|
||||
interactive = false,
|
||||
onNodeClick,
|
||||
onPaneClick,
|
||||
}: WorkflowPreviewProps & {
|
||||
selectedBlock?: string
|
||||
interactive?: boolean
|
||||
onNodeClick?: (blockId: string) => void
|
||||
onPaneClick?: () => void
|
||||
}) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate, { highlightBlock, highlightEdge, selectedBlock }),
|
||||
[workflow, animate, highlightBlock, highlightEdge, selectedBlock]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
/**
|
||||
* Apply data changes (highlight/selection) without discarding positions the
|
||||
* viewer has dragged — only a different workflow should relayout the canvas.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setNodes((prev) => {
|
||||
const positions = new Map(prev.map((node) => [node.id, node.position]))
|
||||
return initialNodes.map((node) => {
|
||||
const position = positions.get(node.id)
|
||||
return position ? { ...node, position } : node
|
||||
})
|
||||
})
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges])
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
)
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={interactive}
|
||||
zoomOnDoubleClick={interactive}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch
|
||||
panOnDrag
|
||||
preventScrolling={interactive}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
minZoom={0.1}
|
||||
fitView
|
||||
fitViewOptions={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only, app-styled workflow diagram for docs pages. Renders a
|
||||
* {@link PreviewWorkflow} with ReactFlow — draggable, non-editable, no app
|
||||
* runtime. Clicking a block (or the expand control) opens a full-screen
|
||||
* lightbox with zoom and pan, plus a read-only inspector panel showing the
|
||||
* selected block's full configuration — canvas rows truncate, the inspector
|
||||
* doesn't.
|
||||
*
|
||||
* @example
|
||||
* <WorkflowPreview workflow={CLASSIFY_WORKFLOW} />
|
||||
*/
|
||||
export function WorkflowPreview({
|
||||
workflow,
|
||||
height = 340,
|
||||
animate = false,
|
||||
highlightBlock,
|
||||
highlightEdge,
|
||||
}: WorkflowPreviewProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setExpanded(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.classList.add('wp-lightbox-open')
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previousOverflow
|
||||
document.body.classList.remove('wp-lightbox-open')
|
||||
}
|
||||
}, [expanded])
|
||||
|
||||
const selectedBlock = selectedId
|
||||
? (workflow.blocks.find((b) => b.id === selectedId) ?? null)
|
||||
: null
|
||||
|
||||
const openWith = (blockId: string | null) => {
|
||||
setSelectedId(blockId)
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div
|
||||
className='not-prose group relative my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
style={{ height }}
|
||||
>
|
||||
<ReactFlowProvider key={`${workflow.id}-${highlightBlock ?? ''}-${highlightEdge ?? ''}`}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
animate={animate}
|
||||
highlightBlock={highlightBlock}
|
||||
highlightEdge={highlightEdge}
|
||||
onNodeClick={(id) => openWith(id)}
|
||||
onPaneClick={() => openWith(null)}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Expand workflow preview'
|
||||
onClick={() => openWith(null)}
|
||||
className='absolute top-2 right-2 z-10 flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] opacity-0 transition-opacity duration-150 hover:text-[var(--text-primary)] group-hover:opacity-100'
|
||||
>
|
||||
<Maximize2 className='size-[13px]' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm'
|
||||
onClick={() => setExpanded(false)}
|
||||
onKeyDown={() => {}}
|
||||
role='presentation'
|
||||
>
|
||||
<div
|
||||
className='relative flex h-[86vh] w-[92vw] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={() => {}}
|
||||
role='presentation'
|
||||
>
|
||||
<div className='relative min-w-0 flex-1'>
|
||||
<div className='pointer-events-none absolute top-0 right-0 left-0 z-10 flex items-center justify-between px-4 py-3'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>{workflow.name}</span>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close'
|
||||
onClick={() => setExpanded(false)}
|
||||
className='pointer-events-auto flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='size-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
<ReactFlowProvider key={`${workflow.id}-lightbox`}>
|
||||
<PreviewFlow
|
||||
workflow={workflow}
|
||||
highlightBlock={highlightBlock}
|
||||
highlightEdge={highlightEdge}
|
||||
selectedBlock={selectedId ?? undefined}
|
||||
interactive
|
||||
onNodeClick={(id) => setSelectedId(id)}
|
||||
onPaneClick={() => setSelectedId(null)}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
<div className='w-[340px] flex-shrink-0 border-[var(--border)] border-l'>
|
||||
{selectedBlock ? (
|
||||
<BlockInspector
|
||||
embedded
|
||||
name={selectedBlock.name}
|
||||
type={selectedBlock.type}
|
||||
color={selectedBlock.bgColor}
|
||||
fields={inspectorFieldsFor(selectedBlock)}
|
||||
tools={selectedBlock.tools}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-6 text-center text-[13px] text-[var(--text-muted)]'>
|
||||
Select a block to see its full configuration
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LazyMotion>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user