mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
48 Commits
waleedlati
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49cd0beebc | ||
|
|
be4ea4be05 | ||
|
|
ae080f125c | ||
|
|
0fb840c8fd | ||
|
|
2c20519bbd | ||
|
|
f3474b0c90 | ||
|
|
b2cc5b6738 | ||
|
|
d49a2c1c25 | ||
|
|
8fa4745893 | ||
|
|
c168e36a05 | ||
|
|
9cc46ffa43 | ||
|
|
cc5e592c46 | ||
|
|
7276136398 | ||
|
|
3ad7af4b97 | ||
|
|
3cb1768a44 | ||
|
|
11e6387a7d | ||
|
|
57a91027de | ||
|
|
49c29d5f7d | ||
|
|
843af915bc | ||
|
|
bb3e899f74 | ||
|
|
e47dcdcc43 | ||
|
|
3e6cf24762 | ||
|
|
90a12546b2 | ||
|
|
b6f8439267 | ||
|
|
4f74a8b845 | ||
|
|
f12d8f631f | ||
|
|
41f0957ccc | ||
|
|
7b813be1dd | ||
|
|
704fa16bb4 | ||
|
|
eccad2a8ce | ||
|
|
87f5c464d9 | ||
|
|
724aaa1432 | ||
|
|
3de3ef4786 | ||
|
|
743f048442 | ||
|
|
bbcf346df0 | ||
|
|
b9c3c2f78f | ||
|
|
d333307a17 | ||
|
|
134c4c4f2a | ||
|
|
03908edcbb | ||
|
|
3112485c31 | ||
|
|
459c2930ae | ||
|
|
3338b25c30 | ||
|
|
4c3002f97d | ||
|
|
632e0e0762 | ||
|
|
7599774974 | ||
|
|
471e58a2d0 | ||
|
|
231ddc59a0 | ||
|
|
b197f68828 |
26
.cursor/rules/landing-seo-geo.mdc
Normal file
26
.cursor/rules/landing-seo-geo.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: SEO and GEO guidelines for the landing page
|
||||
globs: ["apps/sim/app/(home)/**/*.tsx"]
|
||||
---
|
||||
|
||||
# Landing Page — SEO / GEO
|
||||
|
||||
## SEO
|
||||
|
||||
- One `<h1>` per page, in Hero only — never add another.
|
||||
- Strict heading hierarchy: H1 (Hero) → H2 (section titles) → H3 (feature names).
|
||||
- Every section: `<section id="…" aria-labelledby="…-heading">`.
|
||||
- Decorative/animated elements: `aria-hidden="true"`.
|
||||
- All internal routes use Next.js `<Link>` (crawlable). External links get `rel="noopener noreferrer"`.
|
||||
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element).
|
||||
- Navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
|
||||
- Feature lists must stay in sync with `WebApplication.featureList` in `structured-data.tsx`.
|
||||
|
||||
## GEO (Generative Engine Optimisation)
|
||||
|
||||
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
|
||||
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
|
||||
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
|
||||
- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI agents", "agentic workflows".
|
||||
- **sr-only summaries**: Hero and Templates each have a `<p className="sr-only">` (~50 words) as an atomic product/catalog summary for AI citation.
|
||||
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.
|
||||
@@ -4,7 +4,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
||||
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
|
||||
@@ -264,15 +264,17 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI automation',
|
||||
'knowledge base',
|
||||
'AI integrations',
|
||||
data.title?.toLowerCase().split(' '),
|
||||
]
|
||||
.flat()
|
||||
@@ -282,7 +284,8 @@ export async function generateMetadata(props: {
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
url: fullUrl,
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
@@ -303,7 +306,8 @@ export async function generateMetadata(props: {
|
||||
card: 'summary_large_image',
|
||||
title: data.title,
|
||||
description:
|
||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
data.description ||
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim Documentation',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI Agent Workflows.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://docs.sim.ai',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
@@ -99,6 +99,19 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
</head>
|
||||
<body className='flex min-h-screen flex-col font-sans'>
|
||||
<Script src='https://assets.onedollarstats.com/stonks.js' strategy='lazyOnload' />
|
||||
{process.env.REACT_GRAB_ENABLED === 'TRUE' && (
|
||||
<Script
|
||||
src='https://unpkg.com/react-grab/dist/index.global.js'
|
||||
crossOrigin='anonymous'
|
||||
strategy='beforeInteractive'
|
||||
/>
|
||||
)}
|
||||
{process.env.REACT_GRAB_ENABLED === 'TRUE' && (
|
||||
<Script
|
||||
src='https://unpkg.com/@react-grab/cursor/dist/client.global.js'
|
||||
strategy='lazyOnload'
|
||||
/>
|
||||
)}
|
||||
<RootProvider i18n={provider(lang)}>
|
||||
<Navbar />
|
||||
<DocsLayout
|
||||
|
||||
@@ -21,6 +21,13 @@ body {
|
||||
|
||||
.dark {
|
||||
--color-fd-primary: #33c482;
|
||||
--color-fd-background: #1c1c1c;
|
||||
--color-fd-card: #1b1b1b;
|
||||
--color-fd-muted: #1b1b1b;
|
||||
--color-fd-secondary: #1b1b1b;
|
||||
--color-fd-popover: #1b1b1b;
|
||||
--color-fd-border: #2a2a2a;
|
||||
--color-fd-accent: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Font family utilities */
|
||||
@@ -77,9 +84,9 @@ body {
|
||||
|
||||
/* Dark mode navbar and search styling */
|
||||
:root.dark nav {
|
||||
background-color: hsla(0, 0%, 7.04%, 0.92) !important;
|
||||
backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
|
||||
background-color: rgba(28, 28, 28, 0.92) !important;
|
||||
backdrop-filter: blur(25px) saturate(180%) !important;
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%) !important;
|
||||
}
|
||||
|
||||
/* Floating sidebar appearance - remove background */
|
||||
@@ -483,9 +490,9 @@ pre code {
|
||||
|
||||
/* Dark mode inline code */
|
||||
.dark :not(pre) > code {
|
||||
background-color: rgb(31 41 55);
|
||||
background-color: #1b1b1b;
|
||||
color: rgb(248 113 113);
|
||||
border: 1px solid rgb(55 65 81);
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
/* Code block container improvements */
|
||||
|
||||
@@ -7,26 +7,27 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
},
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
keywords: [
|
||||
'AI workflow builder',
|
||||
'visual workflow editor',
|
||||
'AI automation',
|
||||
'workflow automation',
|
||||
'AI agents',
|
||||
'no-code AI',
|
||||
'drag and drop workflows',
|
||||
'agentic workforce',
|
||||
'AI agent platform',
|
||||
'open-source AI agents',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI integrations',
|
||||
'workflow canvas',
|
||||
'AI Agent Workflow Builder',
|
||||
'workflow orchestration',
|
||||
'agent builder',
|
||||
'AI workflow automation',
|
||||
'visual programming',
|
||||
'knowledge base',
|
||||
'AI automation',
|
||||
'workflow builder',
|
||||
'AI workflow orchestration',
|
||||
'enterprise AI',
|
||||
'AI agent deployment',
|
||||
'intelligent automation',
|
||||
'AI tools',
|
||||
],
|
||||
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
|
||||
creator: 'Sim',
|
||||
@@ -53,9 +54,9 @@ export const metadata = {
|
||||
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
|
||||
url: 'https://docs.sim.ai',
|
||||
siteName: 'Sim Documentation',
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
|
||||
@@ -67,9 +68,9 @@ export const metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],
|
||||
|
||||
@@ -37,9 +37,9 @@ export async function GET() {
|
||||
|
||||
const manifest = `# Sim Documentation
|
||||
|
||||
> Visual Workflow Builder for AI Applications
|
||||
> The open-source platform to build AI agents and run your agentic workforce.
|
||||
|
||||
Sim is a visual workflow builder for AI applications that lets you build AI agent workflows visually. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders.
|
||||
|
||||
## Documentation Overview
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export function TOCFooter() {
|
||||
<div className='text-balance font-semibold text-base leading-tight'>
|
||||
Start building today
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Trusted by over 60,000 builders.</div>
|
||||
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
|
||||
The open-source platform to build AI agents and run your agentic workforce.
|
||||
</div>
|
||||
<Link
|
||||
href='https://sim.ai/signup'
|
||||
|
||||
@@ -74,7 +74,7 @@ export function StructuredData({
|
||||
name: 'Sim Documentation',
|
||||
url: baseUrl,
|
||||
description:
|
||||
'Comprehensive documentation for Sim visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
@@ -104,7 +104,7 @@ export function StructuredData({
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
description:
|
||||
'Visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
url: baseUrl,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
@@ -115,12 +115,13 @@ export function StructuredData({
|
||||
category: 'Developer Tools',
|
||||
},
|
||||
featureList: [
|
||||
'Visual workflow builder with drag-and-drop interface',
|
||||
'AI agent creation and automation',
|
||||
'80+ built-in integrations',
|
||||
'Real-time team collaboration',
|
||||
'Multiple deployment options',
|
||||
'Custom integrations via MCP protocol',
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
336
apps/docs/components/ui/docs-background.tsx
Normal file
336
apps/docs/components/ui/docs-background.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
/**
|
||||
* Static block pattern SVG rects matching the hero page's color palette.
|
||||
* These are arranged in a horizontal strip, similar to BlocksTopRightAnimated.
|
||||
*/
|
||||
const BLOCK_COLORS = ['#2ABBF8', '#00F701', '#FFCC02', '#FA4EDF'] as const
|
||||
const RX = '2.59574'
|
||||
|
||||
/** Decorative background for the docs site (dark mode only).
|
||||
* Renders card-left.svg, union-right.svg, and static block patterns. */
|
||||
export function DocsBackground() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted || resolvedTheme !== 'dark') return null
|
||||
|
||||
return (
|
||||
<div aria-hidden='true' className='pointer-events-none fixed inset-0 z-0 overflow-hidden'>
|
||||
{/* Card-left SVG — top left */}
|
||||
<div className='absolute top-[-0.7vw] left-[-2.8vw] aspect-[344/328] w-[23.9vw] opacity-40'>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
{/* Card-right SVG — top right */}
|
||||
<div className='absolute top-[-2.8vw] right-[0vw] aspect-[471/470] w-[32.7vw] opacity-40'>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
{/* Union-right SVG — bottom right */}
|
||||
<div className='absolute right-[-20%] bottom-[-10%] w-[75%] rotate-90 opacity-60'>
|
||||
<Image
|
||||
src='/landing/union-right.svg'
|
||||
alt=''
|
||||
width={768}
|
||||
height={768}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Static block strip — top right area */}
|
||||
<div className='absolute top-[10px] right-[13vw] w-[calc(140px_+_10.76vw)] max-w-[295px] opacity-60'>
|
||||
<svg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
<rect opacity='0.6' width='85.3433' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='1' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='0.6' x='34.2403' width='34.2403' height='33.7252' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='1' x='34.2403' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='51.6188'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#2ABBF8'
|
||||
/>
|
||||
<rect opacity='1' x='68.4812' width='54.6502' height='16.8626' rx={RX} fill='#00F701' />
|
||||
<rect opacity='0.6' x='106.268' width='34.2403' height='33.7252' rx={RX} fill='#00F701' />
|
||||
<rect opacity='0.6' x='106.268' width='51.103' height='16.8626' rx={RX} fill='#00F701' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='123.6484'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
/>
|
||||
<rect opacity='0.6' x='157.371' width='34.2403' height='16.8626' rx={RX} fill='#FFCC02' />
|
||||
<rect opacity='1' x='157.371' width='16.8626' height='16.8626' rx={RX} fill='#FFCC02' />
|
||||
<rect opacity='0.6' x='208.993' width='68.4805' height='16.8626' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='0.6' x='209.137' width='16.8626' height='33.7252' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='0.6' x='243.233' width='34.2403' height='33.7252' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='1' x='243.233' width='16.8626' height='16.8626' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='0.6' x='260.096' width='34.04' height='16.8626' rx={RX} fill='#FA4EDF' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='260.611'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Static block strip — top left area */}
|
||||
<div className='absolute top-[10px] left-[16vw] w-[calc(140px_+_10.76vw)] max-w-[295px] opacity-60'>
|
||||
<svg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
<rect opacity='0.6' width='85.3433' height='16.8626' rx={RX} fill='#00F701' />
|
||||
<rect opacity='1' width='16.8626' height='16.8626' rx={RX} fill='#00F701' />
|
||||
<rect opacity='0.6' x='34.2403' width='34.2403' height='33.7252' rx={RX} fill='#00F701' />
|
||||
<rect opacity='1' x='34.2403' width='16.8626' height='16.8626' rx={RX} fill='#00F701' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='51.6188'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
/>
|
||||
<rect opacity='1' x='68.4812' width='54.6502' height='16.8626' rx={RX} fill='#FFCC02' />
|
||||
<rect opacity='0.6' x='106.268' width='34.2403' height='33.7252' rx={RX} fill='#FFCC02' />
|
||||
<rect opacity='0.6' x='106.268' width='51.103' height='16.8626' rx={RX} fill='#FFCC02' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='123.6484'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FFCC02'
|
||||
/>
|
||||
<rect opacity='0.6' x='157.371' width='34.2403' height='16.8626' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='1' x='157.371' width='16.8626' height='16.8626' rx={RX} fill='#FA4EDF' />
|
||||
<rect opacity='0.6' x='208.993' width='68.4805' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='0.6' x='209.137' width='16.8626' height='33.7252' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='0.6' x='243.233' width='34.2403' height='33.7252' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='1' x='243.233' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect opacity='0.6' x='260.096' width='34.04' height='16.8626' rx={RX} fill='#2ABBF8' />
|
||||
<rect
|
||||
opacity='1'
|
||||
x='260.611'
|
||||
y='16.8626'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#2ABBF8'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Vertical block strip — left edge */}
|
||||
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px_+_1.25vw)] max-w-[34px] opacity-60'>
|
||||
<svg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='34.240'
|
||||
height='33.725'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 0 0)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='68.480'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(-1 0 0 1 33.727 0)'
|
||||
/>
|
||||
<rect
|
||||
opacity='1'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(-1 0 0 1 33.727 17.378)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='33.986'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 0 51.616)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='140.507'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
transform='matrix(-1 0 0 1 33.986 85.335)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.4'
|
||||
x='17.119'
|
||||
y='136.962'
|
||||
width='34.240'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FFCC02'
|
||||
transform='rotate(-90 17.119 136.962)'
|
||||
/>
|
||||
<rect
|
||||
opacity='1'
|
||||
x='17.119'
|
||||
y='136.962'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FFCC02'
|
||||
transform='rotate(-90 17.119 136.962)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.5'
|
||||
width='34.240'
|
||||
height='33.725'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
transform='matrix(0 1 1 0 0.257 153.825)'
|
||||
/>
|
||||
<rect
|
||||
opacity='1'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
transform='matrix(0 1 1 0 0.257 153.825)'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Vertical block strip — right edge */}
|
||||
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px_+_1.25vw)] max-w-[34px] opacity-60'>
|
||||
<svg
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='33.726'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 0 0)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='34.241'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 16.891 0)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='68.482'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(-1 0 0 1 33.739 16.888)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='33.726'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 0 33.776)'
|
||||
/>
|
||||
<rect
|
||||
opacity='1'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(-1 0 0 1 33.739 34.272)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='33.726'
|
||||
rx={RX}
|
||||
fill='#FA4EDF'
|
||||
transform='matrix(0 1 1 0 0.012 68.510)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.6'
|
||||
width='16.8626'
|
||||
height='102.384'
|
||||
rx={RX}
|
||||
fill='#2ABBF8'
|
||||
transform='matrix(-1 0 0 1 33.787 102.384)'
|
||||
/>
|
||||
<rect
|
||||
opacity='0.4'
|
||||
x='17.131'
|
||||
y='153.859'
|
||||
width='34.241'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
transform='rotate(-90 17.131 153.859)'
|
||||
/>
|
||||
<rect
|
||||
opacity='1'
|
||||
x='17.131'
|
||||
y='153.859'
|
||||
width='16.8626'
|
||||
height='16.8626'
|
||||
rx={RX}
|
||||
fill='#00F701'
|
||||
transform='rotate(-90 17.131 153.859)'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/docs/public/landing/card-left.svg
Normal file
3
apps/docs/public/landing/card-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="344" height="328" viewBox="0 0 344 328" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 513 B |
3
apps/docs/public/landing/card-right.svg
Normal file
3
apps/docs/public/landing/card-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="471" height="470" viewBox="0 0 471 470" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
6
apps/docs/public/landing/union-right.svg
Normal file
6
apps/docs/public/landing/union-right.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 768.219 767.667" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Union">
|
||||
<path d="M715.886 0.820573C744.399 1.18152 767.402 24.4083 767.403 53.0071V150.79C767.403 179.389 744.4 202.616 715.886 202.977L715.212 202.982H586.265C583.868 202.982 582.266 205.495 582.968 207.787C583.989 211.117 584.538 214.654 584.538 218.319V345.442C584.538 365.287 568.45 381.375 548.605 381.375H348.717C346.913 381.375 345.45 382.838 345.45 384.642V730.917C345.45 750.763 329.362 766.851 309.517 766.851H36.7503C16.9049 766.851 0.816756 750.763 0.816667 730.917V218.319C0.816667 198.473 16.9048 182.385 36.7503 182.385H164.698C166.503 182.385 167.965 180.922 167.965 179.118V53.0081C167.965 24.1843 191.332 0.817072 220.156 0.816667H715.212L715.886 0.820573ZM220.156 39.9602C212.95 39.9606 207.109 45.8024 207.109 53.0081V160.571C207.109 162.376 208.571 163.838 210.375 163.838H715.212C722.418 163.838 728.26 157.996 728.26 150.79V53.0071C728.26 45.8016 722.418 39.9604 715.212 39.9602H220.156Z" fill="var(--fill-0, #1C1C1C)"/>
|
||||
<path d="M715.886 0.820573L715.896 0.00395244L715.891 0.00391996L715.886 0.820573ZM767.403 53.0071H768.219V53.0071L767.403 53.0071ZM715.886 202.977L715.892 203.793L715.896 203.793L715.886 202.977ZM715.212 202.982V203.798L715.218 203.798L715.212 202.982ZM584.538 345.442H585.355V345.442H584.538ZM548.605 381.375V382.192V382.192V381.375ZM345.45 730.917H346.267V730.917H345.45ZM309.517 766.851V767.667V767.667V766.851ZM0.816667 730.917H0V730.917H0.816667ZM167.965 53.0081L167.148 53.0081V53.0081H167.965ZM220.156 0.816667V0H220.156L220.156 0.816667ZM715.212 0.816667L715.217 0H715.212V0.816667ZM220.156 39.9602V39.1436H220.155L220.156 39.9602ZM207.109 53.0081L206.292 53.008V53.0081H207.109ZM715.212 163.838V164.655V164.655V163.838ZM728.26 53.0071H729.077V53.007L728.26 53.0071ZM715.212 39.9602V39.1436V39.1436V39.9602ZM582.968 207.787L583.749 207.548L582.968 207.787ZM715.886 0.820573L715.876 1.63717C743.943 1.99247 766.585 24.8559 766.586 53.0071L767.403 53.0071L768.219 53.0071C768.219 23.9608 744.856 0.370568 715.896 0.0039717L715.886 0.820573ZM767.403 53.0071H766.586V150.79H767.403H768.219V53.0071H767.403ZM767.403 150.79H766.586C766.586 178.942 743.943 201.805 715.876 202.16L715.886 202.977L715.896 203.793C744.856 203.427 768.219 179.837 768.219 150.79H767.403ZM715.886 202.977L715.88 202.16L715.206 202.165L715.212 202.982L715.218 203.798L715.892 203.793L715.886 202.977ZM715.212 202.982V202.165H586.265V202.982V203.798H715.212V202.982ZM582.968 207.787L582.188 208.026C583.184 211.28 583.722 214.736 583.722 218.319H584.538H585.355C585.355 214.572 584.793 210.955 583.749 207.548L582.968 207.787ZM584.538 218.319H583.722V345.442H584.538H585.355V218.319H584.538ZM584.538 345.442H583.722C583.722 364.836 567.999 380.559 548.605 380.559V381.375V382.192C568.901 382.192 585.355 365.738 585.355 345.442H584.538ZM548.605 381.375V380.559H348.717V381.375V382.192H548.605V381.375ZM345.45 384.642H344.634V730.917H345.45H346.267V384.642H345.45ZM345.45 730.917H344.634C344.634 750.312 328.911 766.034 309.517 766.034V766.851V767.667C329.813 767.667 346.267 751.214 346.267 730.917H345.45ZM309.517 766.851V766.034H36.7503V766.851V767.667H309.517V766.851ZM36.7503 766.851V766.034C17.3559 766.034 1.63342 750.312 1.63333 730.917H0.816667H0C9.16123e-05 751.214 16.4538 767.667 36.7503 767.667V766.851ZM0.816667 730.917H1.63333V218.319H0.816667H0V730.917H0.816667ZM0.816667 218.319H1.63333C1.63333 198.924 17.3559 183.202 36.7503 183.202V182.385V181.568C16.4538 181.568 0 198.022 0 218.319H0.816667ZM36.7503 182.385V183.202H164.698V182.385V181.568H36.7503V182.385ZM167.965 179.118H168.782V53.0081H167.965H167.148V179.118H167.965ZM167.965 53.0081L168.782 53.0081C168.782 24.6353 191.783 1.63373 220.156 1.63333L220.156 0.816667L220.156 0C190.881 0.00041157 167.149 23.7333 167.148 53.0081L167.965 53.0081ZM220.156 0.816667V1.63333H715.212V0.816667V0H220.156V0.816667ZM715.212 0.816667L715.207 1.63332L715.881 1.63723L715.886 0.820573L715.891 0.00391996L715.217 1.37091e-05L715.212 0.816667ZM220.156 39.9602L220.155 39.1436C212.499 39.144 206.292 45.3514 206.292 53.008L207.109 53.0081L207.925 53.0081C207.926 46.2534 213.401 40.7773 220.156 40.7769L220.156 39.9602ZM207.109 53.0081H206.292V160.571H207.109H207.925V53.0081H207.109ZM210.375 163.838V164.655H715.212V163.838V163.021H210.375V163.838ZM715.212 163.838V164.655C722.869 164.655 729.077 158.447 729.077 150.79H728.26H727.443C727.443 157.545 721.967 163.021 715.212 163.021V163.838ZM728.26 150.79H729.077V53.0071H728.26H727.443V150.79H728.26ZM728.26 53.0071L729.077 53.007C729.076 45.3505 722.869 39.1437 715.212 39.1436V39.9602V40.7769C721.967 40.7771 727.443 46.2527 727.443 53.0072L728.26 53.0071ZM715.212 39.9602V39.1436H220.156V39.9602V40.7769H715.212V39.9602ZM207.109 160.571H206.292C206.292 162.827 208.12 164.655 210.375 164.655V163.838V163.021C209.022 163.021 207.925 161.925 207.925 160.571H207.109ZM164.698 182.385V183.202C166.954 183.202 168.782 181.374 168.782 179.118H167.965H167.148C167.148 180.471 166.052 181.568 164.698 181.568V182.385ZM348.717 381.375V380.559C346.462 380.559 344.634 382.387 344.634 384.642H345.45H346.267C346.267 383.289 347.364 382.192 348.717 382.192V381.375ZM586.265 202.982V202.165C583.235 202.165 581.35 205.293 582.188 208.026L582.968 207.787L583.749 207.548C583.182 205.697 584.502 203.798 586.265 203.798V202.982Z" fill="var(--stroke-0, #323232)" fill-opacity="0.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
332
apps/sim/app/(home)/components/collaboration/collaboration.tsx
Normal file
332
apps/sim/app/(home)/components/collaboration/collaboration.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CURSOR_KEYFRAMES = `
|
||||
@keyframes cursorVikhyath {
|
||||
0% { transform: translate(0, 0); }
|
||||
12% { transform: translate(120px, 10px); }
|
||||
24% { transform: translate(80px, 80px); }
|
||||
36% { transform: translate(-10px, 60px); }
|
||||
48% { transform: translate(-15px, -20px); }
|
||||
60% { transform: translate(100px, -40px); }
|
||||
72% { transform: translate(180px, 30px); }
|
||||
84% { transform: translate(50px, 50px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@keyframes cursorAlexa {
|
||||
0% { transform: translate(0, 0); }
|
||||
14% { transform: translate(45px, -35px); }
|
||||
28% { transform: translate(-75px, 20px); }
|
||||
42% { transform: translate(25px, -50px); }
|
||||
57% { transform: translate(-65px, 15px); }
|
||||
71% { transform: translate(35px, -30px); }
|
||||
85% { transform: translate(-30px, -10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes cursorVikhyath { 0%, 100% { transform: none; } }
|
||||
@keyframes cursorAlexa { 0%, 100% { transform: none; } }
|
||||
}
|
||||
`
|
||||
|
||||
const CURSOR_ARROW_PATH =
|
||||
'M17.135 2.198L12.978 14.821C12.478 16.339 10.275 16.16 10.028 14.581L9.106 8.703C9.01 8.092 8.554 7.599 7.952 7.457L1.591 5.953C0 5.577 0.039 3.299 1.642 2.978L15.39 0.229C16.534 0 17.499 1.09 17.135 2.198Z'
|
||||
|
||||
const CURSOR_ARROW_MIRRORED_PATH =
|
||||
'M0.365 2.198L4.522 14.821C5.022 16.339 7.225 16.16 7.472 14.58L8.394 8.702C8.49 8.091 8.946 7.599 9.548 7.456L15.909 5.953C17.5 5.577 17.461 3.299 15.857 2.978L2.11 0.228C0.966 0 0.001 1.09 0.365 2.198Z'
|
||||
|
||||
function CursorArrow({ fill }: { fill: string }) {
|
||||
return (
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
<path d={fill === '#2ABBF8' ? CURSOR_ARROW_PATH : CURSOR_ARROW_MIRRORED_PATH} fill={fill} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VikhyathCursor() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
top: '27.47%',
|
||||
left: '25%',
|
||||
animation: 'cursorVikhyath 16s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[37.14px] w-[79.18px]'>
|
||||
<div className='absolute top-0 left-[56.02px]'>
|
||||
<CursorArrow fill='#2ABBF8' />
|
||||
</div>
|
||||
<div className='-left-[4px] absolute top-[18px] flex items-center rounded bg-[#2ABBF8] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
Vikhyath
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlexaCursor() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
top: '66.80%',
|
||||
left: '49%',
|
||||
animation: 'cursorAlexa 13s ease-in-out infinite',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div className='relative h-[35.09px] w-[62.16px]'>
|
||||
<div className='absolute top-0 left-0'>
|
||||
<CursorArrow fill='#FFCC02' />
|
||||
</div>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#FFCC02] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
Alexa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface YouCursorProps {
|
||||
x: number
|
||||
y: number
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
function YouCursor({ x, y, visible }: YouCursorProps) {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none fixed z-50'
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
transform: 'translate(-2px, -2px)',
|
||||
}}
|
||||
>
|
||||
<svg width='23.15' height='21.1' viewBox='0 0 17.5 16.4' fill='none'>
|
||||
<path d={CURSOR_ARROW_MIRRORED_PATH} fill='#33C482' />
|
||||
</svg>
|
||||
<div className='absolute top-[16px] left-[23px] flex items-center rounded bg-[#33C482] px-[5px] py-[3px] font-[420] font-season text-[#202020] text-[14px] leading-[100%] tracking-[-0.02em]'>
|
||||
You
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration section — team workflows and real-time collaboration.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="collaboration" aria-labelledby="collaboration-heading">`.
|
||||
* - `<h2 id="collaboration-heading">` for the section title.
|
||||
* - Product visuals use `<figure>` with `<figcaption>` and descriptive `alt` text.
|
||||
*
|
||||
* GEO:
|
||||
* - Name specific capabilities (version control, shared workspaces, RBAC, audit logs).
|
||||
* - Lead with a summary so AI can answer "Does Sim support team collaboration?".
|
||||
* - Reference "Sim" by name per capability ("Sim's real-time collaboration").
|
||||
*/
|
||||
|
||||
const CURSOR_LERP_FACTOR = 0.3
|
||||
|
||||
export default function Collaboration() {
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 })
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const targetPos = useRef({ x: 0, y: 0 })
|
||||
const animationRef = useRef<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
setCursorPos((prev) => ({
|
||||
x: prev.x + (targetPos.current.x - prev.x) * CURSOR_LERP_FACTOR,
|
||||
y: prev.y + (targetPos.current.y - prev.y) * CURSOR_LERP_FACTOR,
|
||||
}))
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
if (isHovering) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [isHovering])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
targetPos.current = { x: e.clientX, y: e.clientY }
|
||||
}, [])
|
||||
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent) => {
|
||||
targetPos.current = { x: e.clientX, y: e.clientY }
|
||||
setCursorPos({ x: e.clientX, y: e.clientY })
|
||||
setIsHovering(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovering(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='collaboration'
|
||||
aria-labelledby='collaboration-heading'
|
||||
className='bg-[#1C1C1C]'
|
||||
style={{ cursor: isHovering ? 'none' : 'auto' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<YouCursor x={cursorPos.x} y={cursorPos.y} visible={isHovering} />
|
||||
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<Link
|
||||
href='/studio/multiplayer'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='absolute bottom-10 left-4 z-20 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:left-8 md:left-[80px]'
|
||||
>
|
||||
<div className='relative h-7 w-11 shrink-0'>
|
||||
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
|
||||
Blog
|
||||
</span>
|
||||
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
How we built realtime collaboration
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className='grid grid-cols-[auto_1fr]'>
|
||||
<div className='flex flex-col items-start gap-3 px-4 pt-[100px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#33C482]/10 font-season text-[#33C482] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Teams
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='collaboration-heading'
|
||||
className='font-[430] font-season text-[32px] text-white leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Realtime
|
||||
<br />
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Grab your team. Build agents together <br /> in real-time inside your workspace.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<figure className='pointer-events-none relative h-[600px] w-full'>
|
||||
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
|
||||
<Image
|
||||
src='/landing/collaboration-visual.svg'
|
||||
alt='Collaboration visual showing team workflows with real-time editing, shared cursors, and version control interface'
|
||||
width={876}
|
||||
height={480}
|
||||
className='h-full w-auto min-w-[100vw] object-left'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
<VikhyathCursor />
|
||||
<AlexaCursor />
|
||||
</div>
|
||||
<figcaption className='sr-only'>
|
||||
Sim collaboration interface with real-time cursors, shared workspace, and team
|
||||
presence indicators
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Enterprise section — compliance, scale, and security messaging.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
|
||||
* - `<h2 id="enterprise-heading">` for the section title.
|
||||
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
|
||||
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
|
||||
*
|
||||
* GEO:
|
||||
* - Entity-rich: "Sim is SOC2 and HIPAA compliant" — not "We are compliant."
|
||||
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
|
||||
* as an atomic answer block for "What enterprise features does Sim offer?".
|
||||
*/
|
||||
export default function Enterprise() {
|
||||
return null
|
||||
}
|
||||
229
apps/sim/app/(home)/components/features/features.tsx
Normal file
229
apps/sim/app/(home)/components/features/features.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
label: 'Integrations',
|
||||
color: '#FA4EDF',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.25, 10],
|
||||
[0.45, 12],
|
||||
[0.5, 8],
|
||||
[0.65, 10],
|
||||
[0.8, 12],
|
||||
[0.75, 8],
|
||||
[0.95, 10],
|
||||
[1, 12],
|
||||
[0.85, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Copilot',
|
||||
color: '#2ABBF8',
|
||||
segments: [
|
||||
[0.25, 12],
|
||||
[0.4, 10],
|
||||
[0.35, 8],
|
||||
[0.55, 12],
|
||||
[0.7, 10],
|
||||
[0.85, 8],
|
||||
[1, 14],
|
||||
[0.9, 12],
|
||||
[1, 14],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Models',
|
||||
color: '#00F701',
|
||||
badgeColor: '#22C55E',
|
||||
segments: [
|
||||
[0.2, 6],
|
||||
[0.35, 10],
|
||||
[0.3, 8],
|
||||
[0.5, 10],
|
||||
[0.6, 8],
|
||||
[0.75, 12],
|
||||
[0.85, 10],
|
||||
[1, 8],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 6],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deploy',
|
||||
color: '#FFCC02',
|
||||
badgeColor: '#EAB308',
|
||||
segments: [
|
||||
[0.3, 12],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.55, 10],
|
||||
[0.7, 8],
|
||||
[0.6, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
color: '#8B5CF6',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 10],
|
||||
[0.8, 10],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function DotGrid({
|
||||
cols,
|
||||
rows,
|
||||
width,
|
||||
borderLeft,
|
||||
}: {
|
||||
cols: number
|
||||
rows: number
|
||||
width?: number
|
||||
borderLeft?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap: 4,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
return (
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
src='/landing/features-transition.svg'
|
||||
alt=''
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
backgroundColor: hexToRgba(
|
||||
FEATURE_TABS[activeTab].badgeColor ?? FEATURE_TABS[activeTab].color,
|
||||
0.1
|
||||
),
|
||||
}}
|
||||
>
|
||||
Features
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Power your AI workforce
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={index === activeTab}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||
*
|
||||
* SEO:
|
||||
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||
* - External links include `rel="noopener noreferrer"`.
|
||||
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||
*
|
||||
* GEO:
|
||||
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
|
||||
/** Stagger between each block appearing (seconds). */
|
||||
const ENTER_STAGGER = 0.06
|
||||
|
||||
/** Duration of each block's fade-in (seconds). */
|
||||
const ENTER_DURATION = 0.3
|
||||
|
||||
/** Stagger between each block disappearing (seconds). */
|
||||
const EXIT_STAGGER = 0.12
|
||||
|
||||
/** Duration of each block's fade-out (seconds). */
|
||||
const EXIT_DURATION = 0.5
|
||||
|
||||
/** Shared corner radius for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
/** Hold time after the initial enter animation before cycling starts (ms). */
|
||||
const INITIAL_HOLD_MS = 2500
|
||||
|
||||
/** Pause between an exit completing and the next enter starting (ms). */
|
||||
const TRANSITION_PAUSE_MS = 400
|
||||
|
||||
/** Hold time between successive transitions (ms). */
|
||||
const HOLD_BETWEEN_MS = 2500
|
||||
|
||||
/** Animation state for a block group. */
|
||||
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
/** Positions around the hero where block groups can appear. */
|
||||
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
|
||||
|
||||
/** Attributes for a single animated SVG rect. */
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
height: string
|
||||
fill: string
|
||||
x?: string
|
||||
y?: string
|
||||
transform?: string
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||
exit: { transition: { staggerChildren: EXIT_STAGGER } },
|
||||
}
|
||||
|
||||
const containerVariantsReverseExit: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||
exit: { transition: { staggerChildren: EXIT_STAGGER, staggerDirection: -1 } },
|
||||
}
|
||||
|
||||
const blockVariants: Variants = {
|
||||
hidden: { opacity: 0, transition: { duration: 0 } },
|
||||
visible: (targetOpacity: number) => ({
|
||||
opacity: targetOpacity,
|
||||
transition: { duration: ENTER_DURATION },
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: EXIT_DURATION },
|
||||
},
|
||||
}
|
||||
|
||||
/** Maps a BlockAnimState to the framer-motion animate value. */
|
||||
function toAnimateValue(state: BlockAnimState): string {
|
||||
if (state === 'entering' || state === 'visible') return 'visible'
|
||||
if (state === 'exiting') return 'exit'
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
/** Shared SVG wrapper that staggers child rects in and out. */
|
||||
function AnimatedBlocksSvg({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState = 'entering',
|
||||
reverseExit = false,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState?: BlockAnimState
|
||||
reverseExit?: boolean
|
||||
}) {
|
||||
return (
|
||||
<motion.svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
initial='hidden'
|
||||
animate={toAnimateValue(animState)}
|
||||
variants={reverseExit ? containerVariantsReverseExit : containerVariants}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
variants={blockVariants}
|
||||
custom={r.opacity}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.width}
|
||||
height={r.height}
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect data for the top-right position.
|
||||
* Two-row horizontal strip, ordered left-to-right.
|
||||
*/
|
||||
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the top-left position.
|
||||
* Same two-row structure as top-right with rotated colour palette:
|
||||
* blue→green, green→yellow, yellow→pink, pink→blue.
|
||||
*/
|
||||
const TOP_LEFT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the left position.
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const LEFT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-side position (right edge of screenshot).
|
||||
* Same two-column structure as left with rotated colours:
|
||||
* pink→blue, green→pink, yellow→green.
|
||||
*/
|
||||
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-edge position (far right of screen).
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 16.891 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.482',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 16.888)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 33.776)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 34.272)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
]
|
||||
|
||||
/** Number of rects per position, used to compute animation durations. */
|
||||
const RECT_COUNTS: Record<BlockPosition, number> = {
|
||||
topRight: TOP_RIGHT_RECTS.length,
|
||||
topLeft: TOP_LEFT_RECTS.length,
|
||||
left: LEFT_RECTS.length,
|
||||
rightSide: RIGHT_SIDE_RECTS.length,
|
||||
rightEdge: RIGHT_RECTS.length,
|
||||
}
|
||||
|
||||
/** Total enter animation time for a position (seconds). */
|
||||
function enterTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
/** Total exit animation time for a position (seconds). */
|
||||
function exitTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
/** A single step in the repeating animation cycle. */
|
||||
type CycleStep =
|
||||
| { action: 'exit'; position: BlockPosition }
|
||||
| { action: 'enter'; position: BlockPosition }
|
||||
| { action: 'hold'; ms: number }
|
||||
|
||||
/**
|
||||
* The repeating cycle sequence. After all steps, the layout returns to its
|
||||
* initial state (topRight + left + rightEdge) so the loop is seamless.
|
||||
*
|
||||
* Order: exit top → exit right-edge → enter right-side-of-preview →
|
||||
* exit left → enter top-left → exit right-side → enter left →
|
||||
* exit top-left → enter top-right → enter right-edge → back to initial.
|
||||
*/
|
||||
const CYCLE_STEPS: readonly CycleStep[] = [
|
||||
{ action: 'exit', position: 'topRight' },
|
||||
{ action: 'exit', position: 'rightEdge' },
|
||||
{ action: 'enter', position: 'rightSide' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'left' },
|
||||
{ action: 'enter', position: 'topLeft' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'rightSide' },
|
||||
{ action: 'enter', position: 'left' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'topLeft' },
|
||||
{ action: 'enter', position: 'topRight' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'enter', position: 'rightEdge' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
]
|
||||
|
||||
/**
|
||||
* Drives the block-cycling animation loop. Returns the current animation
|
||||
* state for every position so each component can be driven declaratively.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. All three initial groups (topRight, left, rightEdge) enter together.
|
||||
* 2. After a hold period the cycle begins, processing each step in order.
|
||||
* 3. Repeats indefinitely, returning to the initial layout every cycle.
|
||||
*/
|
||||
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
|
||||
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
|
||||
topRight: 'entering',
|
||||
left: 'entering',
|
||||
rightEdge: 'entering',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const cancelled = { current: false }
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const run = async () => {
|
||||
const longestEnter = Math.max(
|
||||
enterTime('topRight'),
|
||||
enterTime('left'),
|
||||
enterTime('rightEdge')
|
||||
)
|
||||
await delay(longestEnter * 1000)
|
||||
if (cancelled.current) return
|
||||
|
||||
setStates({
|
||||
topRight: 'visible',
|
||||
left: 'visible',
|
||||
rightEdge: 'visible',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
await delay(INITIAL_HOLD_MS)
|
||||
if (cancelled.current) return
|
||||
|
||||
while (!cancelled.current) {
|
||||
for (const step of CYCLE_STEPS) {
|
||||
if (cancelled.current) return
|
||||
|
||||
if (step.action === 'exit') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
|
||||
await delay(exitTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else if (step.action === 'enter') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
|
||||
await delay(enterTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else {
|
||||
await delay(step.ms)
|
||||
}
|
||||
|
||||
if (cancelled.current) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
interface AnimatedBlockProps {
|
||||
animState?: BlockAnimState
|
||||
reverseExit?: boolean
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-right of the hero. */
|
||||
export function BlocksTopRightAnimated({
|
||||
animState = 'entering',
|
||||
reverseExit,
|
||||
}: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_RIGHT_RECTS}
|
||||
animState={animState}
|
||||
reverseExit={reverseExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-left of the hero. */
|
||||
export function BlocksTopLeftAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_LEFT_RECTS}
|
||||
animState={animState}
|
||||
reverseExit={reverseExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the left edge of the screenshot. */
|
||||
export function BlocksLeftAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={LEFT_RECTS}
|
||||
animState={animState}
|
||||
reverseExit={reverseExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the right edge of the screenshot. */
|
||||
export function BlocksRightSideAnimated({
|
||||
animState = 'entering',
|
||||
reverseExit,
|
||||
}: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RIGHT_SIDE_RECTS}
|
||||
animState={animState}
|
||||
reverseExit={reverseExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip at the far-right edge of the screen. */
|
||||
export function BlocksRightAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RIGHT_RECTS}
|
||||
animState={animState}
|
||||
reverseExit={reverseExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
132
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
132
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BlocksLeftAnimated,
|
||||
BlocksRightAnimated,
|
||||
BlocksRightSideAnimated,
|
||||
BlocksTopLeftAnimated,
|
||||
BlocksTopRightAnimated,
|
||||
useBlockCycle,
|
||||
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(home)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||
|
||||
export default function Hero() {
|
||||
const blockStates = useBlockCycle()
|
||||
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||
HIPAA compliant.
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Build Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Build and deploy agentic workflows
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksLeftAnimated animState={blockStates.left} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||
>
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/(home)/components/index.ts
Normal file
23
apps/sim/app/(home)/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
|
||||
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
|
||||
import Features from '@/app/(home)/components/features/features'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Hero from '@/app/(home)/components/hero/hero'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Pricing from '@/app/(home)/components/pricing/pricing'
|
||||
import StructuredData from '@/app/(home)/components/structured-data'
|
||||
import Templates from '@/app/(home)/components/templates/templates'
|
||||
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
|
||||
*
|
||||
* Structure mirrors the real Panel component:
|
||||
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const router = useRouter()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const isEmpty = inputValue.trim().length === 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
LandingPromptStorage.store(inputValue)
|
||||
router.push('/signup')
|
||||
}, [isEmpty, inputValue, router])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-[14px]'>
|
||||
{/* Header — More + Chat | Deploy + Run */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
|
||||
<div className='pointer-events-none flex gap-[6px]'>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex gap-[6px]'
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
</Link>
|
||||
{cursorPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className='pointer-events-none fixed z-[9999]'
|
||||
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||
>
|
||||
{/* Decorative color bars — mirrors hero top-right block sequence */}
|
||||
<div className='flex h-[4px]'>
|
||||
<div className='h-full w-[8px] bg-[#2ABBF8]' />
|
||||
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#00F701]' />
|
||||
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FFCC02]' />
|
||||
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
|
||||
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[5px] bg-white px-[6px] py-[4px] font-medium text-[#1C1C1C] text-[11px]'>
|
||||
Get started
|
||||
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className='flex flex-shrink-0 items-center px-[8px] pt-[14px]'>
|
||||
<div className='pointer-events-none flex gap-[4px]'>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
|
||||
</div>
|
||||
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content — copilot */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[12px]'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
|
||||
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-[12px] py-[6px]'>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
|
||||
</div>
|
||||
|
||||
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
|
||||
<div className='px-[8px] pt-[12px] pb-[8px]'>
|
||||
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-[6px] py-[6px]'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Build an AI agent...'
|
||||
rows={2}
|
||||
className='mb-[6px] min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-[2px] py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||
import { ChevronDown, Library } from '@/components/emcn'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* Props for the LandingPreviewSidebar component
|
||||
*/
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
onSelectWorkflow: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Static footer navigation items matching the real sidebar
|
||||
*/
|
||||
const FOOTER_NAV_ITEMS = [
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||
*
|
||||
* Colors sourced from the dark theme CSS variables:
|
||||
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
onSelectWorkflow,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsDropdownOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isDropdownOpen])
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||
{/* Header */}
|
||||
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||
>
|
||||
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace switcher dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||
<div
|
||||
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||
role='menuitem'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow items */}
|
||||
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div
|
||||
className={`min-w-0 truncate text-left font-medium ${
|
||||
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||
}`}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer navigation — static */}
|
||||
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||
{FOOTER_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
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 { PreviewBlockNode } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewWorkflow,
|
||||
toReactFlowElements,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
interface FitViewOptions {
|
||||
padding?: number
|
||||
maxZoom?: number
|
||||
}
|
||||
|
||||
interface LandingPreviewWorkflowProps {
|
||||
workflow: PreviewWorkflow
|
||||
animate?: boolean
|
||||
fitViewOptions?: FitViewOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom edge that draws left-to-right on initial load via stroke animation.
|
||||
* Falls back to a static path when `data.animate` is false.
|
||||
*/
|
||||
function PreviewEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
return (
|
||||
<motion.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: PreviewBlockNode }
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1 } as const
|
||||
|
||||
/**
|
||||
* Inner flow component. Keyed on workflow ID by the parent so it remounts
|
||||
* cleanly on workflow switch — fitView fires on mount with zero delay.
|
||||
*/
|
||||
function PreviewFlow({ workflow, animate = false, fitViewOptions }: LandingPreviewWorkflowProps) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => toReactFlowElements(workflow, animate),
|
||||
[workflow, animate]
|
||||
)
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[]
|
||||
)
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||
[]
|
||||
)
|
||||
|
||||
const resolvedFitViewOptions = fitViewOptions ?? DEFAULT_FIT_VIEW_OPTIONS
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
elementsSelectable={false}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
panOnDrag
|
||||
preventScrolling={false}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
fitView
|
||||
fitViewOptions={resolvedFitViewOptions}
|
||||
className='h-full w-full bg-[#1b1b1b]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
|
||||
* The key on workflow.id forces a clean remount on switch — instant fitView,
|
||||
* no timers, no flicker.
|
||||
*/
|
||||
export function LandingPreviewWorkflow({
|
||||
workflow,
|
||||
animate = false,
|
||||
fitViewOptions,
|
||||
}: LandingPreviewWorkflowProps) {
|
||||
return (
|
||||
<div className='h-full w-full'>
|
||||
<ReactFlowProvider key={workflow.id}>
|
||||
<PreviewFlow workflow={workflow} animate={animate} fitViewOptions={fitViewOptions} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
FirecrawlIcon,
|
||||
GeminiIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleSheetsIcon,
|
||||
JiraIcon,
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
MistralIcon,
|
||||
NotionIcon,
|
||||
OpenAIIcon,
|
||||
RedditIcon,
|
||||
ReductoIcon,
|
||||
ScheduleIcon,
|
||||
SlackIcon,
|
||||
StartIcon,
|
||||
SupabaseIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
WebhookIcon,
|
||||
xAIIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
} from '@/components/icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/** Map block type strings to their icon components. */
|
||||
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
starter: StartIcon,
|
||||
start_trigger: StartIcon,
|
||||
agent: AgentIcon,
|
||||
slack: SlackIcon,
|
||||
jira: JiraIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
schedule: ScheduleIcon,
|
||||
telegram: TelegramIcon,
|
||||
knowledge_base: Database,
|
||||
webhook: WebhookIcon,
|
||||
github: GithubIcon,
|
||||
supabase: SupabaseIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
gmail: GmailIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
linear: LinearIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
reddit: RedditIcon,
|
||||
notion: NotionIcon,
|
||||
reducto: ReductoIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
const MODEL_PROVIDER_ICONS: Array<{
|
||||
prefix: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
size?: string
|
||||
}> = [
|
||||
{ prefix: 'gpt-', icon: OpenAIIcon },
|
||||
{ prefix: 'o3', icon: OpenAIIcon },
|
||||
{ prefix: 'o4', icon: OpenAIIcon },
|
||||
{ prefix: 'claude-', icon: AnthropicIcon },
|
||||
{ prefix: 'gemini-', icon: GeminiIcon },
|
||||
{ prefix: 'grok-', icon: xAIIcon, size: 'h-[17px] w-[17px]' },
|
||||
{ prefix: 'mistral-', icon: MistralIcon },
|
||||
]
|
||||
|
||||
function getModelIconEntry(modelValue: string) {
|
||||
const lower = modelValue.toLowerCase()
|
||||
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Data shape for preview block nodes
|
||||
*/
|
||||
interface PreviewBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle styling matching the real WorkflowBlock handles.
|
||||
* --workflow-edge in dark mode: #454545
|
||||
*/
|
||||
const HANDLE_BASE = '!z-[10] !border-none !bg-[#454545]'
|
||||
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
|
||||
/**
|
||||
* Static preview block node matching the real WorkflowBlock styling.
|
||||
* Renders a block header with icon + name, sub-block rows, and tool chips.
|
||||
*
|
||||
* Colors sourced from dark theme CSS variables:
|
||||
* --surface-2: #232323, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3
|
||||
*/
|
||||
export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
data,
|
||||
}: NodeProps<PreviewBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows,
|
||||
tools,
|
||||
markdown,
|
||||
hideTargetHandle,
|
||||
hideSourceHandle,
|
||||
index = 0,
|
||||
animate = false,
|
||||
} = data
|
||||
const Icon = BLOCK_ICONS[blockType]
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
|
||||
if (blockType === 'note' && markdown) {
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
<div className='border-[#3d3d3d] border-b p-[8px]'>
|
||||
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||
</div>
|
||||
<div className='p-[10px]'>
|
||||
<NoteMarkdown content={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasContent = rows.length > 0 || (tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='relative'
|
||||
initial={animate ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||
{/* Target handle (left side) */}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-[8px] ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='h-[16px] w-[16px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-block rows + tools */}
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{rows.map((row) => {
|
||||
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
|
||||
const ModelIcon = modelEntry?.icon
|
||||
return (
|
||||
<div key={row.title} className='flex items-center gap-[8px]'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-[5px] font-normal text-[#e6e6e6] text-[14px]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[#e6e6e6] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tool chips — inline with label */}
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||
>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && <ToolIcon className='h-[10px] w-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='font-normal text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle (right side) */}
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders lightweight markdown-like content for note blocks.
|
||||
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
|
||||
*/
|
||||
function NoteMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return <div key={i} className='h-[4px]' />
|
||||
|
||||
if (trimmed === '---') {
|
||||
return <hr key={i} className='my-[4px] border-[#3d3d3d] border-t' />
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
return (
|
||||
<p key={i} className='font-semibold text-[#e6e6e6] text-[16px] leading-[1.3]'>
|
||||
{trimmed.slice(4)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className='font-medium text-[#e6e6e6] text-[13px] leading-[1.5]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: trimmed
|
||||
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/_"(.+?)"_/g, '<em>“$1”</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { Position } from 'reactflow'
|
||||
|
||||
/**
|
||||
* Tool entry displayed as a chip on agent blocks
|
||||
*/
|
||||
export interface PreviewTool {
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Static block definition for preview workflow nodes
|
||||
*/
|
||||
export interface PreviewBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
tools?: PreviewTool[]
|
||||
markdown?: string
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow definition containing nodes, edges, and metadata
|
||||
*/
|
||||
export interface PreviewWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
blocks: PreviewBlock[]
|
||||
edges: Array<{ id: string; source: string; target: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* IT Service Management workflow — Slack Trigger -> Agent (KB tool) -> Jira
|
||||
*/
|
||||
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-it-service',
|
||||
name: 'IT Service Management',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#it-support' },
|
||||
{ title: 'Event', value: 'New Message' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' }],
|
||||
position: { x: 420, y: 40 },
|
||||
},
|
||||
{
|
||||
id: 'jira-1',
|
||||
name: 'Jira',
|
||||
type: 'jira',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Get Issues' },
|
||||
{ title: 'Project', value: 'IT-Support' },
|
||||
],
|
||||
position: { x: 420, y: 260 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 80, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty "New Agent" workflow — a single note prompting the user to start building
|
||||
*/
|
||||
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-new-agent',
|
||||
name: 'New Agent',
|
||||
color: '#787878',
|
||||
blocks: [
|
||||
{
|
||||
id: 'note-1',
|
||||
name: '',
|
||||
type: 'note',
|
||||
bgColor: 'transparent',
|
||||
rows: [],
|
||||
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
|
||||
position: { x: 0, y: 0 },
|
||||
hideTargetHandle: true,
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
/** Stagger delay between each block appearing (seconds). */
|
||||
export const BLOCK_STAGGER = 0.12
|
||||
|
||||
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||
|
||||
/** Shared edge style applied to all preview workflow connections */
|
||||
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||
|
||||
/**
|
||||
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||
*
|
||||
* @param workflow - The workflow definition
|
||||
* @param animate - When true, node/edge data includes animation metadata
|
||||
*/
|
||||
export function toReactFlowElements(
|
||||
workflow: PreviewWorkflow,
|
||||
animate = false
|
||||
): {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
} {
|
||||
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||
|
||||
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||
id: block.id,
|
||||
type: 'previewBlock',
|
||||
position: block.position,
|
||||
data: {
|
||||
name: block.name,
|
||||
blockType: block.type,
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
tools: block.tools,
|
||||
markdown: block.markdown,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
index,
|
||||
animate,
|
||||
},
|
||||
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
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: EDGE_STYLE,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
import {
|
||||
EASE_OUT,
|
||||
PREVIEW_WORKFLOWS,
|
||||
} from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.15 },
|
||||
},
|
||||
}
|
||||
|
||||
const sidebarVariants: Variants = {
|
||||
hidden: { opacity: 0, x: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
x: { duration: 0.25, ease: EASE_OUT },
|
||||
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const panelVariants: Variants = {
|
||||
hidden: { opacity: 0, x: 12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
x: { duration: 0.25, ease: EASE_OUT },
|
||||
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive workspace preview for the hero section.
|
||||
*
|
||||
* Renders a lightweight replica of the Sim workspace with:
|
||||
* - A sidebar with two selectable workflows
|
||||
* - A ReactFlow canvas showing the active workflow's blocks and edges
|
||||
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
|
||||
*
|
||||
* Everything except the workflow items and the copilot input is non-interactive.
|
||||
* On mount the sidebar slides from left and the panel from right. The canvas
|
||||
* background stays fully opaque; individual block nodes animate in with a
|
||||
* staggered fade. Edges draw left-to-right. Animations only fire on initial
|
||||
* load — workflow switches render instantly.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onSelectWorkflow={setActiveWorkflowId}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
</div>
|
||||
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
|
||||
const logger = createLogger('github-stars')
|
||||
|
||||
const INITIAL_STARS = '26.4k'
|
||||
|
||||
/**
|
||||
* Client component that displays GitHub stars count.
|
||||
*
|
||||
* Isolated as a client component to allow the parent Navbar to remain
|
||||
* a Server Component for optimal SEO/GEO crawlability.
|
||||
*/
|
||||
export function GitHubStars() {
|
||||
const [stars, setStars] = useState(INITIAL_STARS)
|
||||
|
||||
useEffect(() => {
|
||||
getFormattedGitHubStars()
|
||||
.then(setStars)
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to fetch GitHub stars', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-[8px] px-[12px]'
|
||||
aria-label={`GitHub repository — ${stars} stars`}
|
||||
>
|
||||
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||
<span aria-live='polite'>{stars}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
97
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
97
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||
|
||||
interface NavLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
icon?: 'chevron'
|
||||
}
|
||||
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: '/docs', icon: 'chevron' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
]
|
||||
|
||||
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
|
||||
const LOGO_CELL = 'flex items-center px-[20px]'
|
||||
|
||||
/** Links: even spacing between items. */
|
||||
const LINK_CELL = 'flex items-center px-[14px]'
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<nav
|
||||
aria-label='Primary navigation'
|
||||
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
Sim
|
||||
</span>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Links */}
|
||||
<ul className='mt-[0.75px] flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon }) => (
|
||||
<li key={label} className='flex'>
|
||||
{external ? (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
{icon === 'chevron' && (
|
||||
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className='flex'>
|
||||
<GitHubStars />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='flex-1' />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
218
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
218
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
|
||||
interface PricingTier {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
billingPeriod?: string
|
||||
color: string
|
||||
features: string[]
|
||||
cta: { label: string; href: string }
|
||||
}
|
||||
|
||||
const PRICING_TIERS: PricingTier[] = [
|
||||
{
|
||||
id: 'community',
|
||||
name: 'Community',
|
||||
description: 'For individuals getting started with AI agents',
|
||||
price: 'Free',
|
||||
color: '#2ABBF8',
|
||||
features: [
|
||||
'$20 usage limit',
|
||||
'5GB file storage',
|
||||
'5 min execution limit',
|
||||
'Limited log retention',
|
||||
'CLI/SDK Access',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
name: 'Professional',
|
||||
description: 'For professionals building production workflows',
|
||||
price: '$20',
|
||||
billingPeriod: 'per month',
|
||||
color: '#00F701',
|
||||
features: [
|
||||
'150 runs per minute (sync)',
|
||||
'1,000 runs per minute (async)',
|
||||
'50 min sync execution limit',
|
||||
'50GB file storage',
|
||||
'Unlimited invites',
|
||||
'Unlimited log retention',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
name: 'Team',
|
||||
description: 'For teams collaborating on complex agents',
|
||||
price: '$40',
|
||||
billingPeriod: 'per month',
|
||||
color: '#FA4EDF',
|
||||
features: [
|
||||
'300 runs per minute (sync)',
|
||||
'2,500 runs per minute (async)',
|
||||
'500GB file storage (pooled)',
|
||||
'50 min sync execution limit',
|
||||
'Unlimited invites',
|
||||
'Unlimited log retention',
|
||||
'Dedicated Slack channel',
|
||||
],
|
||||
cta: { label: 'Get started', href: '/signup' },
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'For organizations needing security and scale',
|
||||
price: 'Custom',
|
||||
color: '#FFCC02',
|
||||
features: ['Custom rate limits', 'Custom file storage', 'SSO', 'SOC2', 'Dedicated support'],
|
||||
cta: { label: 'Book a demo', href: '/contact' },
|
||||
},
|
||||
]
|
||||
|
||||
function CheckIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
|
||||
<path
|
||||
d='M2.5 7L5.5 10L11.5 4'
|
||||
stroke={color}
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PricingCardProps {
|
||||
tier: PricingTier
|
||||
}
|
||||
|
||||
function PricingCard({ tier }: PricingCardProps) {
|
||||
const isEnterprise = tier.id === 'enterprise'
|
||||
const isProfessional = tier.id === 'professional'
|
||||
|
||||
return (
|
||||
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
|
||||
<div className='flex flex-1 flex-col gap-6 rounded-t-lg border border-[#E5E5E5] border-b-0 bg-white p-5'>
|
||||
<div className='flex flex-col'>
|
||||
<h3
|
||||
id={`${tier.id}-heading`}
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className='mt-2 min-h-[44px] font-[430] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className='mt-4 flex items-center gap-1.5 font-[430] font-season text-[#1C1C1C] text-[20px] leading-[100%] tracking-[-0.02em]'>
|
||||
{tier.price}
|
||||
{tier.billingPeriod && (
|
||||
<span className='text-[#737373] text-[16px]'>{tier.billingPeriod}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
{isEnterprise ? (
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
) : isProfessional ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2'>
|
||||
<CheckIcon color='#404040' />
|
||||
<span className='font-[400] font-season text-[#5c5c5c] text-[14px] leading-[125%] tracking-[0.02em]'>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='relative h-[6px]'>
|
||||
<div
|
||||
className='absolute inset-0 rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[12%] rounded-b-sm opacity-60'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 left-[25%] rounded-b-sm'
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing section — tiered pricing plans with feature comparison.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
|
||||
* - `<h2 id="pricing-heading">` for the section title.
|
||||
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
|
||||
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
|
||||
*
|
||||
* GEO:
|
||||
* - Each plan has consistent structure: name, price, billing period, feature list.
|
||||
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
|
||||
* - Prices must match the `Offer` items in structured-data.tsx exactly.
|
||||
*/
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
|
||||
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='bg-[#2ABBF8]/10 font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
|
||||
>
|
||||
Pricing
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='pricing-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
|
||||
>
|
||||
Pricing
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
{PRICING_TIERS.map((tier) => (
|
||||
<PricingCard key={tier.id} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* JSON-LD structured data for the landing page.
|
||||
*
|
||||
* Renders a `<script type="application/ld+json">` with Schema.org markup.
|
||||
* Single source of truth for machine-readable page metadata.
|
||||
*
|
||||
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
|
||||
*
|
||||
* AI crawler behavior (2025-2026):
|
||||
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
|
||||
* - GPTBot indexes JSON-LD during crawling (92% of LLM crawlers parse JSON-LD first).
|
||||
* - Perplexity / Claude prioritize visible HTML over JSON-LD during direct fetch.
|
||||
* - All claims here must also appear as visible text on the page.
|
||||
*
|
||||
* Maintenance:
|
||||
* - Offer prices must match the Pricing component exactly.
|
||||
* - `sameAs` links must match the Footer social links.
|
||||
* - Do not add `aggregateRating` without real, verifiable review data.
|
||||
*/
|
||||
export default function StructuredData() {
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim Studio',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
'@id': 'https://sim.ai/#logo',
|
||||
url: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
contentUrl: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||
width: 49.78314,
|
||||
height: 24.276,
|
||||
caption: 'Sim Logo',
|
||||
},
|
||||
image: { '@id': 'https://sim.ai/#logo' },
|
||||
sameAs: [
|
||||
'https://x.com/simdotai',
|
||||
'https://github.com/simstudioai/sim',
|
||||
'https://www.linkedin.com/company/simstudioai/',
|
||||
'https://discord.gg/Hr4UWYEcTT',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
availableLanguage: ['en'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||
publisher: { '@id': 'https://sim.ai/#organization' },
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
isPartOf: { '@id': 'https://sim.ai/#website' },
|
||||
about: { '@id': 'https://sim.ai/#software' },
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
|
||||
inLanguage: 'en-US',
|
||||
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Web',
|
||||
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Community Plan',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro Plan',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '20',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Team Plan',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: '40',
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH',
|
||||
billingIncrement: 1,
|
||||
},
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
],
|
||||
featureList: [
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
'Event triggers',
|
||||
],
|
||||
review: [
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||
reviewBody:
|
||||
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Person', name: 'nizzy' },
|
||||
reviewBody:
|
||||
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||
},
|
||||
{
|
||||
'@type': 'Review',
|
||||
author: { '@type': 'Organization', name: 'xyflow' },
|
||||
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://sim.ai/#faq',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Which AI models does Sim support?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How much does Sim cost?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers a free Community plan with $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, and custom Enterprise pricing. All plans include CLI/SDK access.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What enterprise features does Sim offer?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
582
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
582
apps/sim/app/(home)/components/templates/template-workflows.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* OCR Invoice to DB — Start → Agent (Textract) → Supabase
|
||||
* Pattern: Straight line (all blocks aligned at top)
|
||||
*/
|
||||
const OCR_INVOICE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-ocr-invoice',
|
||||
name: 'OCR Invoice to DB',
|
||||
color: '#2ABBF8',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'URL', value: 'invoice.pdf' }],
|
||||
position: { x: 40, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Extract invoice fields...' },
|
||||
],
|
||||
tools: [{ name: 'Textract', type: 'textract', bgColor: '#055F4E' }],
|
||||
position: { x: 400, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'supabase-1',
|
||||
name: 'Supabase',
|
||||
type: 'supabase',
|
||||
bgColor: '#1C1C1C',
|
||||
rows: [
|
||||
{ title: 'Table', value: 'invoices' },
|
||||
{ title: 'Operation', value: 'Insert Row' },
|
||||
],
|
||||
position: { x: 760, y: 80 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-1', target: 'agent-1' },
|
||||
{ id: 'e-2', source: 'agent-1', target: 'supabase-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Release Agent — GitHub → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const GITHUB_RELEASE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-github-release',
|
||||
name: 'GitHub Release Agent',
|
||||
color: '#00F701',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-1',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Release' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Summarize changelog...' },
|
||||
],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#releases' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-1', target: 'agent-2' },
|
||||
{ id: 'e-2', source: 'agent-2', target: 'slack-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting Follow-up Agent — Google Calendar → Agent → Gmail
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const MEETING_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-meeting-followup',
|
||||
name: 'Meeting Follow-up Agent',
|
||||
color: '#FFCC02',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gcal-1',
|
||||
name: 'Google Calendar',
|
||||
type: 'google_calendar',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Meeting Ended' },
|
||||
{ title: 'Calendar', value: 'Work' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Draft follow-up email...' },
|
||||
],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'gmail-1',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Send Email' },
|
||||
{ title: 'To', value: 'attendees' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gcal-1', target: 'agent-3' },
|
||||
{ id: 'e-2', source: 'agent-3', target: 'gmail-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* CV/Resume Scanner — Start → Agent (Reducto) → Google Sheets
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const CV_SCANNER_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-cv-scanner',
|
||||
name: 'CV/Resume Scanner',
|
||||
color: '#FA4EDF',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-2',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'File URL', value: 'resume.pdf' }],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-4',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-opus-4.6' },
|
||||
{ title: 'System Prompt', value: 'Parse resume fields...' },
|
||||
],
|
||||
tools: [{ name: 'Reducto', type: 'reducto', bgColor: '#5c0c5c' }],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-1',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Candidates' },
|
||||
{ title: 'Operation', value: 'Append Row' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-2', target: 'agent-4' },
|
||||
{ id: 'e-2', source: 'agent-4', target: 'gsheets-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Email Triage Agent — Gmail → Agent (KB) → fan-out to Slack + Linear
|
||||
* Pattern: Fan-out (input low → agent mid → outputs spread vertically)
|
||||
*/
|
||||
const EMAIL_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-email-triage',
|
||||
name: 'Email Triage Agent',
|
||||
color: '#FF6B2C',
|
||||
blocks: [
|
||||
{
|
||||
id: 'gmail-2',
|
||||
name: 'Gmail',
|
||||
type: 'gmail',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'New Email' },
|
||||
{ title: 'Label', value: 'Inbox' },
|
||||
],
|
||||
position: { x: 60, y: 130 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-5',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2-mini' },
|
||||
{ title: 'System Prompt', value: 'Classify and route...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 100 },
|
||||
},
|
||||
{
|
||||
id: 'slack-2',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#urgent' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 20 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'linear-1',
|
||||
name: 'Linear',
|
||||
type: 'linear',
|
||||
bgColor: '#5E6AD2',
|
||||
rows: [
|
||||
{ title: 'Project', value: 'Support' },
|
||||
{ title: 'Operation', value: 'Create Issue' },
|
||||
],
|
||||
position: { x: 680, y: 200 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'gmail-2', target: 'agent-5' },
|
||||
{ id: 'e-2', source: 'agent-5', target: 'slack-2' },
|
||||
{ id: 'e-3', source: 'agent-5', target: 'linear-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Competitor Monitor — Schedule → Agent (Firecrawl) → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const COMPETITOR_MONITOR_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-competitor-monitor',
|
||||
name: 'Competitor Monitor',
|
||||
color: '#6366F1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '08:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 50 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-6',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Monitor competitor...' },
|
||||
],
|
||||
tools: [{ name: 'Firecrawl', type: 'firecrawl', bgColor: '#181C1E' }],
|
||||
position: { x: 370, y: 150 },
|
||||
},
|
||||
{
|
||||
id: 'slack-3',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#competitive-intel' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 50 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-1', target: 'agent-6' },
|
||||
{ id: 'e-2', source: 'agent-6', target: 'slack-3' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Social Listening Agent — Schedule → Agent (Reddit + X) → Notion
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const SOCIAL_LISTENING_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-social-listening',
|
||||
name: 'Social Listening Agent',
|
||||
color: '#F43F5E',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-2',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [{ title: 'Run Frequency', value: 'Hourly' }],
|
||||
position: { x: 60, y: 150 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-7',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-flash' },
|
||||
{ title: 'System Prompt', value: 'Track brand mentions...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'Reddit', type: 'reddit', bgColor: '#FF5700' },
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
],
|
||||
position: { x: 370, y: 55 },
|
||||
},
|
||||
{
|
||||
id: 'notion-1',
|
||||
name: 'Notion',
|
||||
type: 'notion',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Database', value: 'Brand Mentions' },
|
||||
{ title: 'Operation', value: 'Create Page' },
|
||||
],
|
||||
position: { x: 680, y: 150 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-2', target: 'agent-7' },
|
||||
{ id: 'e-2', source: 'agent-7', target: 'notion-1' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Enrichment Pipeline — Start → Agent (LinkedIn) → Google Sheets
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const DATA_ENRICHMENT_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-data-enrichment',
|
||||
name: 'Data Enrichment Pipeline',
|
||||
color: '#14B8A6',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-3',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Email', value: 'lead@company.com' }],
|
||||
position: { x: 60, y: 55 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-8',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'mistral-large' },
|
||||
{ title: 'System Prompt', value: 'Enrich lead data...' },
|
||||
],
|
||||
tools: [{ name: 'LinkedIn', type: 'linkedin', bgColor: '#0072B1' }],
|
||||
position: { x: 370, y: 145 },
|
||||
},
|
||||
{
|
||||
id: 'gsheets-2',
|
||||
name: 'Google Sheets',
|
||||
type: 'google_sheets',
|
||||
bgColor: '#E0E0E0',
|
||||
rows: [
|
||||
{ title: 'Spreadsheet', value: 'Lead Database' },
|
||||
{ title: 'Operation', value: 'Update Row' },
|
||||
],
|
||||
position: { x: 680, y: 55 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-3', target: 'agent-8' },
|
||||
{ id: 'e-2', source: 'agent-8', target: 'gsheets-2' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Feedback Digest — Schedule → Agent → Slack
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const FEEDBACK_DIGEST_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-feedback-digest',
|
||||
name: 'Customer Feedback Digest',
|
||||
color: '#F59E0B',
|
||||
blocks: [
|
||||
{
|
||||
id: 'schedule-3',
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
rows: [
|
||||
{ title: 'Run Frequency', value: 'Daily' },
|
||||
{ title: 'Time', value: '09:00 AM' },
|
||||
],
|
||||
position: { x: 60, y: 145 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-9',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||
{ title: 'System Prompt', value: 'Analyze customer feedback...' },
|
||||
],
|
||||
tools: [{ name: 'Airtable', type: 'airtable', bgColor: '#18BFFF' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'slack-4',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#product-feedback' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 145 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'schedule-3', target: 'agent-9' },
|
||||
{ id: 'e-2', source: 'agent-9', target: 'slack-4' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* PR Review Agent — GitHub → Agent → Slack
|
||||
* Pattern: Concave (high → low → high)
|
||||
*/
|
||||
const PR_REVIEW_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-pr-review',
|
||||
name: 'PR Review Agent',
|
||||
color: '#06B6D4',
|
||||
blocks: [
|
||||
{
|
||||
id: 'github-2',
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
bgColor: '#181C1E',
|
||||
rows: [
|
||||
{ title: 'Event', value: 'Pull Request Opened' },
|
||||
{ title: 'Repository', value: 'org/repo' },
|
||||
],
|
||||
position: { x: 60, y: 60 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-10',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gpt-5.2' },
|
||||
{ title: 'System Prompt', value: 'Review code changes...' },
|
||||
],
|
||||
position: { x: 370, y: 155 },
|
||||
},
|
||||
{
|
||||
id: 'slack-5',
|
||||
name: 'Slack',
|
||||
type: 'slack',
|
||||
bgColor: '#611f69',
|
||||
rows: [
|
||||
{ title: 'Channel', value: '#code-reviews' },
|
||||
{ title: 'Operation', value: 'Send Message' },
|
||||
],
|
||||
position: { x: 680, y: 60 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'github-2', target: 'agent-10' },
|
||||
{ id: 'e-2', source: 'agent-10', target: 'slack-5' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge Base QA — Start → Agent (KB) → Response
|
||||
* Pattern: Convex (low → high → low)
|
||||
*/
|
||||
const KNOWLEDGE_QA_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'tpl-knowledge-qa',
|
||||
name: 'Knowledge Base QA',
|
||||
color: '#84CC16',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-4',
|
||||
name: 'Start',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Question', value: 'How do I...' }],
|
||||
position: { x: 60, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-11',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'gemini-2.5-pro' },
|
||||
{ title: 'System Prompt', value: 'Answer using knowledge...' },
|
||||
],
|
||||
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#00B0B0' }],
|
||||
position: { x: 370, y: 50 },
|
||||
},
|
||||
{
|
||||
id: 'starter-5',
|
||||
name: 'Response',
|
||||
type: 'starter',
|
||||
bgColor: '#34B5FF',
|
||||
rows: [{ title: 'Answer', value: 'Based on your docs...' }],
|
||||
position: { x: 680, y: 140 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-1', source: 'starter-4', target: 'agent-11' },
|
||||
{ id: 'e-2', source: 'agent-11', target: 'starter-5' },
|
||||
],
|
||||
}
|
||||
|
||||
export const TEMPLATE_WORKFLOWS: PreviewWorkflow[] = [
|
||||
OCR_INVOICE_WORKFLOW,
|
||||
GITHUB_RELEASE_WORKFLOW,
|
||||
MEETING_FOLLOWUP_WORKFLOW,
|
||||
CV_SCANNER_WORKFLOW,
|
||||
EMAIL_TRIAGE_WORKFLOW,
|
||||
COMPETITOR_MONITOR_WORKFLOW,
|
||||
SOCIAL_LISTENING_WORKFLOW,
|
||||
DATA_ENRICHMENT_WORKFLOW,
|
||||
FEEDBACK_DIGEST_WORKFLOW,
|
||||
PR_REVIEW_WORKFLOW,
|
||||
KNOWLEDGE_QA_WORKFLOW,
|
||||
]
|
||||
549
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
549
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { TEMPLATE_WORKFLOWS } from '@/app/(home)/components/templates/template-workflows'
|
||||
|
||||
const LandingPreviewWorkflow = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
).then((mod) => mod.LandingPreviewWorkflow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='h-full w-full bg-[#1b1b1b]' />,
|
||||
}
|
||||
)
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
const LEFT_WALL_CLIP = 'polygon(0 8px, 100% 0, 100% 100%, 0 100%)'
|
||||
const BOTTOM_WALL_CLIP = 'polygon(0 0, 100% 0, calc(100% - 8px) 100%, 0 100%)'
|
||||
|
||||
interface DepthConfig {
|
||||
color: string
|
||||
segments: readonly (readonly [opacity: number, width: number])[]
|
||||
}
|
||||
|
||||
/** Depth color and gradient segment pattern per template. Segments are `[opacity, width%]` tuples. */
|
||||
const DEPTH_CONFIGS: Record<string, DepthConfig> = {
|
||||
'tpl-ocr-invoice': {
|
||||
color: '#2ABBF8',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.5, 8],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 8],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.9, 7],
|
||||
[0.6, 12],
|
||||
[1, 8],
|
||||
[0.35, 8],
|
||||
],
|
||||
},
|
||||
'tpl-github-release': {
|
||||
color: '#00F701',
|
||||
segments: [
|
||||
[0.4, 8],
|
||||
[0.7, 6],
|
||||
[1, 5],
|
||||
[0.5, 14],
|
||||
[0.85, 8],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 7],
|
||||
[0.45, 8],
|
||||
[1, 8],
|
||||
[0.7, 8],
|
||||
],
|
||||
},
|
||||
'tpl-meeting-followup': {
|
||||
color: '#FFCC02',
|
||||
segments: [
|
||||
[0.5, 12],
|
||||
[0.8, 6],
|
||||
[0.35, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 14],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 8],
|
||||
[1, 6],
|
||||
[0.3, 8],
|
||||
],
|
||||
},
|
||||
'tpl-cv-scanner': {
|
||||
color: '#FA4EDF',
|
||||
segments: [
|
||||
[0.35, 6],
|
||||
[0.6, 10],
|
||||
[0.9, 5],
|
||||
[1, 6],
|
||||
[0.4, 8],
|
||||
[0.75, 12],
|
||||
[0.5, 7],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.8, 8],
|
||||
[0.6, 9],
|
||||
[1, 6],
|
||||
[0.45, 8],
|
||||
],
|
||||
},
|
||||
'tpl-email-triage': {
|
||||
color: '#FF6B2C',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.7, 8],
|
||||
[1, 5],
|
||||
[0.5, 12],
|
||||
[0.85, 6],
|
||||
[0.3, 10],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 12],
|
||||
[1, 8],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-competitor-monitor': {
|
||||
color: '#6366F1',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.55, 10],
|
||||
[0.8, 6],
|
||||
[1, 5],
|
||||
[0.4, 12],
|
||||
[0.7, 7],
|
||||
[0.9, 8],
|
||||
[0.5, 10],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 6],
|
||||
[1, 6],
|
||||
[0.6, 8],
|
||||
],
|
||||
},
|
||||
'tpl-social-listening': {
|
||||
color: '#F43F5E',
|
||||
segments: [
|
||||
[0.5, 10],
|
||||
[0.8, 6],
|
||||
[0.4, 8],
|
||||
[1, 5],
|
||||
[0.6, 12],
|
||||
[0.35, 8],
|
||||
[0.9, 7],
|
||||
[1, 6],
|
||||
[0.5, 10],
|
||||
[0.75, 8],
|
||||
[0.4, 6],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
],
|
||||
},
|
||||
'tpl-data-enrichment': {
|
||||
color: '#14B8A6',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.6, 6],
|
||||
[0.9, 5],
|
||||
[0.4, 12],
|
||||
[1, 6],
|
||||
[0.7, 10],
|
||||
[0.5, 7],
|
||||
[0.85, 8],
|
||||
[1, 5],
|
||||
[0.3, 10],
|
||||
[0.65, 8],
|
||||
[1, 7],
|
||||
[0.5, 8],
|
||||
],
|
||||
},
|
||||
'tpl-feedback-digest': {
|
||||
color: '#F59E0B',
|
||||
segments: [
|
||||
[0.4, 10],
|
||||
[0.65, 6],
|
||||
[0.9, 5],
|
||||
[0.5, 12],
|
||||
[1, 6],
|
||||
[0.35, 8],
|
||||
[0.75, 7],
|
||||
[1, 5],
|
||||
[0.6, 10],
|
||||
[0.85, 8],
|
||||
[0.45, 6],
|
||||
[1, 8],
|
||||
[0.55, 9],
|
||||
],
|
||||
},
|
||||
'tpl-pr-review': {
|
||||
color: '#06B6D4',
|
||||
segments: [
|
||||
[0.35, 8],
|
||||
[0.7, 7],
|
||||
[1, 5],
|
||||
[0.45, 10],
|
||||
[0.8, 6],
|
||||
[0.3, 12],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
[0.9, 7],
|
||||
[0.4, 10],
|
||||
[1, 6],
|
||||
[0.65, 8],
|
||||
[0.5, 7],
|
||||
],
|
||||
},
|
||||
'tpl-knowledge-qa': {
|
||||
color: '#84CC16',
|
||||
segments: [
|
||||
[0.5, 8],
|
||||
[0.75, 6],
|
||||
[0.4, 10],
|
||||
[1, 5],
|
||||
[0.6, 8],
|
||||
[0.85, 7],
|
||||
[0.35, 12],
|
||||
[1, 6],
|
||||
[0.7, 8],
|
||||
[0.45, 10],
|
||||
[0.9, 6],
|
||||
[1, 6],
|
||||
[0.55, 8],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const SCROLL_BLOCK_RX = '2.59574'
|
||||
|
||||
/**
|
||||
* Two-row horizontal block strip for the scroll-driven reveal in the templates section.
|
||||
* Same structural pattern as the hero's top-right blocks with matching colours:
|
||||
* blue (left) → pink (middle) → green (right).
|
||||
*/
|
||||
const SCROLL_BLOCK_RECTS = [
|
||||
{ opacity: 0.6, x: '-34.24', y: '0', width: '34.24', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '-17.38', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.34', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.24', y: '0', width: '34.24', height: '33.73', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.24', y: '0', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.62', y: '16.86', width: '16.86', height: '16.86', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.48', y: '0', width: '54.65', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '34.24', height: '33.73', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '106.27', y: '0', width: '51.10', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '123.65', y: '16.86', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '157.37', y: '0', width: '34.24', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.37', y: '0', width: '16.86', height: '16.86', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.0', y: '0', width: '68.48', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '209.14', y: '0', width: '16.86', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '243.23', y: '0', width: '34.24', height: '33.73', fill: '#00F701' },
|
||||
{ opacity: 1, x: '243.23', y: '0', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '260.10', y: '0', width: '34.04', height: '16.86', fill: '#00F701' },
|
||||
{ opacity: 1, x: '260.61', y: '16.86', width: '16.86', height: '16.86', fill: '#00F701' },
|
||||
] as const
|
||||
|
||||
const SCROLL_BLOCK_MAX_X = Math.max(...SCROLL_BLOCK_RECTS.map((r) => Number.parseFloat(r.x)))
|
||||
const SCROLL_REVEAL_START = 0.05
|
||||
const SCROLL_REVEAL_SPAN = 0.7
|
||||
const SCROLL_FADE_IN = 0.03
|
||||
|
||||
function getScrollBlockThreshold(x: string): number {
|
||||
const normalized = Number.parseFloat(x) / SCROLL_BLOCK_MAX_X
|
||||
return SCROLL_REVEAL_START + (1 - normalized) * SCROLL_REVEAL_SPAN
|
||||
}
|
||||
|
||||
interface ScrollBlockRectProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
rect: (typeof SCROLL_BLOCK_RECTS)[number]
|
||||
}
|
||||
|
||||
/** Renders a single SVG rect whose opacity is driven by scroll progress. */
|
||||
function ScrollBlockRect({ scrollYProgress, rect }: ScrollBlockRectProps) {
|
||||
const threshold = getScrollBlockThreshold(rect.x)
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[threshold, threshold + SCROLL_FADE_IN],
|
||||
[0, rect.opacity]
|
||||
)
|
||||
|
||||
return (
|
||||
<motion.rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx={SCROLL_BLOCK_RX}
|
||||
fill={rect.fill}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildBottomWallStyle(config: DepthConfig) {
|
||||
let pos = 0
|
||||
const stops: string[] = []
|
||||
for (const [opacity, width] of config.segments) {
|
||||
const c = hexToRgba(config.color, opacity)
|
||||
stops.push(`${c} ${pos}%`, `${c} ${pos + width}%`)
|
||||
pos += width
|
||||
}
|
||||
return {
|
||||
clipPath: BOTTOM_WALL_CLIP,
|
||||
background: `linear-gradient(135deg, ${stops.join(', ')})`,
|
||||
}
|
||||
}
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
rows: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gap,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TEMPLATES_PANEL_ID = 'templates-panel'
|
||||
|
||||
export default function Templates() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
const activeWorkflow = TEMPLATE_WORKFLOWS[activeIndex]
|
||||
const activeDepth = DEPTH_CONFIGS[activeWorkflow.id]
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id='templates'
|
||||
aria-labelledby='templates-heading'
|
||||
className='mt-[40px] mb-[80px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim includes {TEMPLATE_WORKFLOWS.length} pre-built workflow templates covering OCR
|
||||
processing, release management, meeting follow-ups, resume scanning, email triage,
|
||||
competitor monitoring, social listening, data enrichment, feedback analysis, code review,
|
||||
and knowledge base Q&A. Each template connects real integrations and LLMs — pick one,
|
||||
customise it, and deploy in minutes.
|
||||
</p>
|
||||
|
||||
<div className='bg-[#1C1C1C]'>
|
||||
<DotGrid
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
cols={120}
|
||||
rows={1}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='relative overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-0 z-20 hidden lg:block'
|
||||
>
|
||||
<svg
|
||||
width={329}
|
||||
height={34}
|
||||
viewBox='-34 0 329 34'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
>
|
||||
{SCROLL_BLOCK_RECTS.map((r, i) => (
|
||||
<ScrollBlockRect key={i} scrollYProgress={scrollYProgress} rect={r} />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className='px-[80px] pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
dot
|
||||
className='font-season uppercase tracking-[0.02em] transition-colors duration-200'
|
||||
style={{
|
||||
color: activeDepth.color,
|
||||
backgroundColor: hexToRgba(activeDepth.color, 0.1),
|
||||
}}
|
||||
>
|
||||
Templates
|
||||
</Badge>
|
||||
|
||||
<h2
|
||||
id='templates-heading'
|
||||
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1'>
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Workflow templates'
|
||||
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
|
||||
>
|
||||
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
id={`template-tab-${index}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
aria-controls={TEMPLATES_PANEL_ID}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'relative text-left',
|
||||
isActive
|
||||
? 'z-10'
|
||||
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
(() => {
|
||||
const depth = DEPTH_CONFIGS[workflow.id]
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-[-8px] bottom-0 left-0 w-2'
|
||||
style={{
|
||||
clipPath: LEFT_WALL_CLIP,
|
||||
backgroundColor: hexToRgba(depth.color, 0.63),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute right-[-8px] bottom-0 left-2 h-2'
|
||||
style={buildBottomWallStyle(depth)}
|
||||
/>
|
||||
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
|
||||
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className='-rotate-90 h-[11px] w-[11px] shrink-0'
|
||||
style={{ color: depth.color }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
|
||||
{workflow.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TEMPLATES_PANEL_ID}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
animate
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
Use template
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotGrid
|
||||
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
|
||||
cols={6}
|
||||
rows={55}
|
||||
gap={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Testimonials section — social proof via user quotes.
|
||||
*
|
||||
* SEO:
|
||||
* - `<section id="testimonials" aria-labelledby="testimonials-heading">`.
|
||||
* - `<h2 id="testimonials-heading">` for the section title.
|
||||
* - Each testimonial: `<blockquote cite="tweet-url">` with `<footer><cite>Author</cite></footer>`.
|
||||
* - Profile images use `loading="lazy"` (below the fold).
|
||||
*
|
||||
* GEO:
|
||||
* - Keep quote text as plain text in `<blockquote>` — not split across `<span>` elements.
|
||||
* - Include full author name + handle (LLMs weigh attributed quotes higher).
|
||||
* - Testimonials mentioning "Sim" by name carry more citation weight.
|
||||
* - Review data here aligns with `review` entries in structured-data.tsx.
|
||||
*/
|
||||
export default function Testimonials() {
|
||||
return null
|
||||
}
|
||||
53
apps/sim/app/(home)/landing.tsx
Normal file
53
apps/sim/app/(home)/landing.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
} from '@/app/(home)/components'
|
||||
import { Footer } from '@/app/(landing)/components'
|
||||
|
||||
/**
|
||||
* Landing page root component.
|
||||
*
|
||||
* ## SEO Architecture
|
||||
* - Single `<h1>` inside Hero (only one per page).
|
||||
* - Heading hierarchy: H1 (Hero) -> H2 (each section) -> H3 (sub-items).
|
||||
* - Semantic landmarks: `<header>`, `<main>`, `<footer>`.
|
||||
* - Every `<section>` has an `id` for anchor linking and `aria-labelledby` for accessibility.
|
||||
* - `StructuredData` emits JSON-LD before any visible content.
|
||||
*
|
||||
* ## GEO Architecture
|
||||
* - Above-fold content (Navbar, Hero) is statically rendered (Server Components where possible)
|
||||
* for immediate availability to AI crawlers.
|
||||
* - Section `id` attributes serve as fragment anchors for precise AI citations.
|
||||
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
|
||||
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
|
||||
* pricing (Pricing) -> enterprise (Enterprise).
|
||||
*/
|
||||
export default async function Landing() {
|
||||
return (
|
||||
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||
<StructuredData />
|
||||
<header>
|
||||
<Navbar />
|
||||
</header>
|
||||
<main>
|
||||
<Hero />
|
||||
<Templates />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
<Pricing />
|
||||
<Enterprise />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer fullWidth={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
apps/sim/app/(home)/layout.tsx
Normal file
18
apps/sim/app/(home)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
/**
|
||||
* Landing page route-group layout.
|
||||
*
|
||||
* Applies landing-specific font CSS variables to the subtree:
|
||||
* - `--font-season` (Season Sans): Headings and display text
|
||||
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
|
||||
*
|
||||
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
|
||||
*
|
||||
* SEO metadata for the `/` route is exported from `app/page.tsx` — not here.
|
||||
* This layout only applies when a `page.tsx` exists inside the `(home)/` route group.
|
||||
*/
|
||||
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export default function StatusIndicator() {
|
||||
aria-label={`System status: ${message}`}
|
||||
>
|
||||
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
||||
<span>{message}</span>
|
||||
<span className='font-[family-name:var(--font-martian-mono)] font-medium uppercase tracking-[-0.24px]'>
|
||||
{message}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,279 +1,231 @@
|
||||
import Link from 'next/link'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import {
|
||||
ComplianceBadges,
|
||||
Logo,
|
||||
SocialLinks,
|
||||
StatusIndicator,
|
||||
} from '@/app/(landing)/components/footer/components'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import { SocialLinks, StatusIndicator } from '@/app/(landing)/components/footer/components'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
|
||||
const VISIBLE_COUNT = 9 as const
|
||||
const DOT_GRID_ROWS = 4 as const
|
||||
const DOT_GRID_GAP = 8 as const
|
||||
|
||||
const LINK_CLASS =
|
||||
'font-[family-name:var(--font-martian-mono)] text-[12px] font-medium uppercase tracking-[-0.24px] text-[#f6f6f0]/60 transition-colors hover:text-white' as const
|
||||
|
||||
interface FooterProps {
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
return (
|
||||
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
|
||||
<footer
|
||||
className={`${martianMono.variable} ${season.variable} relative w-full overflow-hidden bg-[#1C1C1C]`}
|
||||
>
|
||||
{/* Dot grid separator */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(120, 1fr)',
|
||||
gap: 6,
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 120 * DOT_GRID_ROWS }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
fullWidth
|
||||
? 'px-4 pt-[40px] pb-[40px] sm:px-4 sm:pt-[34px] sm:pb-[340px]'
|
||||
: 'px-4 pt-[40px] pb-[40px] sm:px-[50px] sm:pt-[34px] sm:pb-[340px]'
|
||||
? 'mx-auto max-w-[1440px] px-10 py-[48px] sm:px-[120px] sm:py-[56px]'
|
||||
: 'px-10 py-[48px] sm:px-[120px] sm:py-[56px]'
|
||||
}
|
||||
>
|
||||
<div className={`flex gap-[80px] ${fullWidth ? 'justify-center' : ''}`}>
|
||||
{/* Logo and social links */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Logo />
|
||||
<SocialLinks />
|
||||
<ComplianceBadges />
|
||||
<StatusIndicator />
|
||||
</div>
|
||||
|
||||
{/* Links section */}
|
||||
<div>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>More Sim</h2>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href='#pricing'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Enterprise
|
||||
</Link>
|
||||
<Link
|
||||
href='/studio'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Sim Studio
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
href='https://status.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Status
|
||||
</Link>
|
||||
<Link
|
||||
href='/careers'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Careers
|
||||
</Link>
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Terms of Service
|
||||
<div className='flex flex-col gap-[48px]'>
|
||||
{/* Main content row */}
|
||||
<div className='flex flex-col gap-[48px] sm:flex-row sm:justify-between'>
|
||||
{/* Logo and status — left aligned */}
|
||||
<div className='flex flex-col gap-[24px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<svg
|
||||
width='71'
|
||||
height='22'
|
||||
viewBox='0 0 71 22'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<g transform='scale(0.07483)'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M142.793 124.175C142.793 128.925 140.913 133.487 137.577 136.846L137.099 137.327C133.765 140.696 129.236 142.579 124.519 142.579H17.8063C7.97854 142.579 0 150.605 0 160.503V275.91C0 285.808 7.97854 293.834 17.8063 293.834H132.383C142.211 293.834 150.179 285.808 150.179 275.91V167.858C150.179 163.453 151.914 159.226 155.009 156.109C158.095 153.001 162.292 151.253 166.666 151.253H275.166C284.994 151.253 292.962 143.229 292.962 133.33V17.9231C292.962 8.02512 284.994 0 275.166 0H160.588C150.761 0 142.793 8.02512 142.793 17.9231V124.175ZM177.564 24.5671H258.181C263.925 24.5671 268.57 29.2545 268.57 35.0301V116.224C268.57 121.998 263.925 126.687 258.181 126.687H177.564C171.83 126.687 167.175 121.998 167.175 116.224V35.0301C167.175 29.2545 171.83 24.5671 177.564 24.5671Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M275.293 171.578H190.106C179.779 171.578 171.406 180.01 171.406 190.412V275.162C171.406 285.564 179.779 293.996 190.106 293.996H275.293C285.621 293.996 293.994 285.564 293.994 275.162V190.412C293.994 180.01 285.621 171.578 275.293 171.578Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M275.293 171.18H190.106C179.779 171.18 171.406 179.612 171.406 190.014V274.763C171.406 285.165 179.779 293.596 190.106 293.596H275.293C285.621 293.596 293.994 285.165 293.994 274.763V190.014C293.994 179.612 285.621 171.18 275.293 171.18Z'
|
||||
fill='white'
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d='M31.5718 15.845H34.1583C34.1583 16.5591 34.4169 17.1285 34.9342 17.5531C35.4515 17.9584 36.1508 18.1611 37.0321 18.1611C37.9901 18.1611 38.7277 17.9777 39.245 17.611C39.7623 17.225 40.021 16.7135 40.021 16.0766C40.021 15.6134 39.8773 15.2274 39.5899 14.9186C39.3217 14.6098 38.8235 14.3589 38.0955 14.1659L35.6239 13.5869C34.3786 13.2781 33.4494 12.8052 32.8363 12.1683C32.2423 11.5314 31.9454 10.6918 31.9454 9.64957C31.9454 8.78105 32.1657 8.02833 32.6064 7.39142C33.0662 6.7545 33.6889 6.26234 34.4744 5.91494C35.2791 5.56753 36.1987 5.39382 37.2333 5.39382C38.2679 5.39382 39.1588 5.57718 39.906 5.94389C40.6724 6.31059 41.2663 6.82206 41.6878 7.47827C42.1285 8.13449 42.3584 8.91615 42.3776 9.82327H39.7911C39.7719 9.08986 39.5324 8.52049 39.0726 8.11518C38.6128 7.70988 37.9709 7.50722 37.1471 7.50722C36.3041 7.50722 35.6527 7.69058 35.1929 8.05728C34.733 8.42399 34.5031 8.9258 34.5031 9.56272C34.5031 10.5084 35.1929 11.155 36.5723 11.5024L39.0439 12.1104C40.2317 12.3806 41.1226 12.8245 41.7166 13.4421C42.3105 14.0404 42.6075 14.8607 42.6075 15.9029C42.6075 16.7907 42.368 17.5724 41.889 18.2479C41.41 18.9041 40.749 19.4156 39.906 19.7823C39.0822 20.1297 38.1051 20.3034 36.9747 20.3034C35.327 20.3034 34.0146 19.8981 33.0375 19.0875C32.0603 18.2769 31.5718 17.196 31.5718 15.845Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M44.5096 19.956V5.79913C45.5868 6.19296 46.0617 6.19296 47.211 5.79913V19.956H44.5096ZM45.8316 4.86332C45.3526 4.86332 44.9311 4.68962 44.5671 4.34221C44.2222 3.9755 44.0498 3.55089 44.0498 3.06838C44.0498 2.56657 44.2222 2.14196 44.5671 1.79455C44.9311 1.44714 45.3526 1.27344 45.8316 1.27344C46.3297 1.27344 46.7512 1.44714 47.0961 1.79455C47.441 2.14196 47.6134 2.56657 47.6134 3.06838C47.6134 3.55089 47.441 3.9755 47.0961 4.34221C46.7512 4.68962 46.3297 4.86332 45.8316 4.86332Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M51.976 19.956H49.2746V5.79913H51.6887V8.18778C51.976 7.39647 52.5317 6.72555 53.298 6.20444C54.0835 5.66403 55.0319 5.39382 56.1432 5.39382C57.3885 5.39382 58.4231 5.73158 59.247 6.4071C60.0708 7.08261 60.6073 7.98008 60.8563 9.09951H60.3678C60.5594 7.98008 61.0862 7.08261 61.9484 6.4071C62.8106 5.73158 63.8739 5.39382 65.1384 5.39382C66.7478 5.39382 68.0123 5.86668 68.9319 6.8124C69.8516 7.75813 70.3114 9.05126 70.3114 10.6918V19.956H67.6674V11.3577C67.6674 10.2382 67.38 9.37936 66.8053 8.78105C66.2496 8.16344 65.4928 7.85463 64.5349 7.85463C63.8643 7.85463 63.2704 8.00903 62.7531 8.31784C62.2549 8.60735 61.8622 9.03196 61.5748 9.59167C61.2874 10.1514 61.1437 10.8076 61.1437 11.5603V19.956H58.471V11.3287C58.471 10.2093 58.1932 9.36006 57.6376 8.78105C57.082 8.18274 56.3252 7.88358 55.3672 7.88358C54.6966 7.88358 54.1027 8.03798 53.5854 8.34679C53.0873 8.6363 52.6945 9.06091 52.4071 9.62062C52.1197 10.161 51.976 10.8076 51.976 11.5603V19.956Z'
|
||||
fill='white'
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<div className='[&_a:hover]:text-white [&_a]:text-[#808080]'>
|
||||
<StatusIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocks section */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Blocks</h2>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
{/* Link columns — right aligned */}
|
||||
<div className='flex flex-col gap-[48px] sm:flex-row sm:gap-[80px]'>
|
||||
{/* Company links */}
|
||||
<div>
|
||||
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
|
||||
Company
|
||||
</h2>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link href='#pricing' className={LINK_CLASS}>
|
||||
Pricing
|
||||
</Link>
|
||||
<Link
|
||||
href='https://form.typeform.com/to/jqCO12pF'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Enterprise
|
||||
</Link>
|
||||
<Link href='/studio' className={LINK_CLASS}>
|
||||
Sim Studio
|
||||
</Link>
|
||||
<Link href='/changelog' className={LINK_CLASS}>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
href='https://status.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Status
|
||||
</Link>
|
||||
<Link href='/careers' className={LINK_CLASS}>
|
||||
Careers
|
||||
</Link>
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link
|
||||
href='https://trust.delve.co/sim-studio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Trust Center
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocks section */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
|
||||
Blocks
|
||||
</h2>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
{FOOTER_BLOCKS.slice(0, VISIBLE_COUNT).map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{block}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
href='https://docs.sim.ai/blocks'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
className='mt-[24px] inline-block font-[family-name:var(--font-season)] font-medium text-[14px] text-white tracking-[-0.28px] transition-opacity hover:opacity-80'
|
||||
>
|
||||
{block}
|
||||
View all Blocks →
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tools section */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
|
||||
Tools
|
||||
</h2>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
{FOOTER_TOOLS.slice(0, VISIBLE_COUNT).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`whitespace-nowrap ${LINK_CLASS}`}
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href='https://docs.sim.ai/tools'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='mt-[24px] inline-block font-[family-name:var(--font-season)] font-medium text-[14px] text-white tracking-[-0.28px] transition-opacity hover:opacity-80'
|
||||
>
|
||||
View all Tools →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section - split into columns */}
|
||||
<div className='hidden sm:block'>
|
||||
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Tools</h2>
|
||||
<div className='flex gap-[80px]'>
|
||||
{/* First column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(0, Math.ceil(FOOTER_TOOLS.length / 4)).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Second column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(
|
||||
Math.ceil(FOOTER_TOOLS.length / 4),
|
||||
Math.ceil((FOOTER_TOOLS.length * 2) / 4)
|
||||
).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Third column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(
|
||||
Math.ceil((FOOTER_TOOLS.length * 2) / 4),
|
||||
Math.ceil((FOOTER_TOOLS.length * 3) / 4)
|
||||
).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Fourth column */}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{FOOTER_TOOLS.slice(Math.ceil((FOOTER_TOOLS.length * 3) / 4)).map((tool) => (
|
||||
<Link
|
||||
key={tool}
|
||||
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{tool}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Social links — bottom */}
|
||||
<div className='[&_a:hover]:text-white [&_a]:text-[#808080]'>
|
||||
<SocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large SIM logo at bottom - half cut off */}
|
||||
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1128'
|
||||
height='550'
|
||||
viewBox='0 0 1128 550'
|
||||
fill='none'
|
||||
>
|
||||
<g filter='url(#filter0_dd_122_4989)'>
|
||||
<path
|
||||
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
|
||||
fill='#DCDCDC'
|
||||
/>
|
||||
<path
|
||||
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
|
||||
stroke='#C1C1C1'
|
||||
strokeWidth='1.28396'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_dd_122_4989'
|
||||
x='0'
|
||||
y='0'
|
||||
width='1128'
|
||||
height='550'
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feColorMatrix
|
||||
in='SourceAlpha'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='hardAlpha'
|
||||
/>
|
||||
<feMorphology
|
||||
radius='1'
|
||||
operator='erode'
|
||||
in='SourceAlpha'
|
||||
result='effect1_dropShadow_122_4989'
|
||||
/>
|
||||
<feOffset dy='1' />
|
||||
<feGaussianBlur stdDeviation='1' />
|
||||
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in2='BackgroundImageFix'
|
||||
result='effect1_dropShadow_122_4989'
|
||||
/>
|
||||
<feColorMatrix
|
||||
in='SourceAlpha'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='hardAlpha'
|
||||
/>
|
||||
<feOffset dy='1' />
|
||||
<feGaussianBlur stdDeviation='1.5' />
|
||||
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in2='effect1_dropShadow_122_4989'
|
||||
result='effect2_dropShadow_122_4989'
|
||||
/>
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='effect2_dropShadow_122_4989'
|
||||
result='shape'
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function StructuredData() {
|
||||
name: 'Sim',
|
||||
alternateName: 'Sim',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
url: 'https://sim.ai',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
@@ -36,9 +36,9 @@ export default function StructuredData() {
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://sim.ai/#website',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||
publisher: {
|
||||
'@id': 'https://sim.ai/#organization',
|
||||
},
|
||||
@@ -48,7 +48,7 @@ export default function StructuredData() {
|
||||
'@type': 'WebPage',
|
||||
'@id': 'https://sim.ai/#webpage',
|
||||
url: 'https://sim.ai',
|
||||
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
isPartOf: {
|
||||
'@id': 'https://sim.ai/#website',
|
||||
},
|
||||
@@ -58,7 +58,7 @@ export default function StructuredData() {
|
||||
datePublished: '2024-01-01T00:00:00+00:00',
|
||||
dateModified: new Date().toISOString(),
|
||||
description:
|
||||
'Build and deploy AI agent workflows with Sim. Visual drag-and-drop interface for creating powerful LLM-powered automations.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||
breadcrumb: {
|
||||
'@id': 'https://sim.ai/#breadcrumb',
|
||||
},
|
||||
@@ -85,9 +85,9 @@ export default function StructuredData() {
|
||||
{
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://sim.ai/#software',
|
||||
name: 'Sim - AI Agent Workflow Builder',
|
||||
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 60,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'AI Development Tools',
|
||||
operatingSystem: 'Web, Windows, macOS, Linux',
|
||||
@@ -159,12 +159,13 @@ export default function StructuredData() {
|
||||
worstRating: '1',
|
||||
},
|
||||
featureList: [
|
||||
'Visual workflow builder',
|
||||
'Drag-and-drop interface',
|
||||
'100+ integrations',
|
||||
'AI model support (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Real-time collaboration',
|
||||
'Version control',
|
||||
'AI agent creation',
|
||||
'Agentic workflow orchestration',
|
||||
'1,000+ integrations',
|
||||
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||
'Knowledge base creation',
|
||||
'Table creation',
|
||||
'Document creation',
|
||||
'API access',
|
||||
'Custom functions',
|
||||
'Scheduled workflows',
|
||||
@@ -174,7 +175,7 @@ export default function StructuredData() {
|
||||
{
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://sim.ai/logo/426-240/primary/small.png',
|
||||
caption: 'Sim AI agent workflow builder interface',
|
||||
caption: 'Sim — build AI agents and run your agentic workforce',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -187,7 +188,7 @@ export default function StructuredData() {
|
||||
name: 'What is Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Sim is an open-source AI agent workflow builder used by 60,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
|
||||
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -203,7 +204,7 @@ export default function StructuredData() {
|
||||
name: 'Do I need coding skills to use Sim?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
|
||||
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@ export function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
href='/studio'
|
||||
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
|
||||
className='group flex items-center gap-1 font-[430] font-season text-[#F6F6F0]/50 text-sm hover:text-[#F6F6F0]/80'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -21,7 +21,7 @@ export function BackLink() {
|
||||
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
Back to Sim Studio
|
||||
All posts
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FAQ } from '@/lib/blog/faq'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
|
||||
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
|
||||
|
||||
@@ -27,6 +26,21 @@ export async function generateMetadata({
|
||||
|
||||
export const revalidate = 86400
|
||||
|
||||
const PROSE_CLASSES = [
|
||||
'prose prose-lg prose-invert max-w-none',
|
||||
'prose-headings:font-season prose-headings:font-[430] prose-headings:text-white prose-headings:tracking-[-0.02em]',
|
||||
'prose-p:text-[#F6F6F0]/80',
|
||||
'prose-a:text-[#33C482] prose-a:no-underline hover:prose-a:text-[#33C482]/80',
|
||||
'prose-strong:text-white',
|
||||
'prose-blockquote:border-[#2A2A2A] prose-blockquote:text-[#F6F6F0]/60',
|
||||
'prose-hr:border-[#2A2A2A]',
|
||||
'prose-li:text-[#F6F6F0]/80',
|
||||
'prose-img:rounded-[10px] prose-img:border prose-img:border-[#2A2A2A]',
|
||||
'[&_code]:!bg-[#2A2A2A] [&_code]:!text-[#F6F6F0]/90 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.875em]',
|
||||
'[&_pre]:!bg-[#222222] [&_pre]:border [&_pre]:border-[#2A2A2A] [&_pre]:rounded-[10px]',
|
||||
'[&_pre_code]:!bg-transparent [&_pre_code]:p-0',
|
||||
].join(' ')
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
@@ -36,11 +50,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
const related = await getRelatedPosts(slug, 3)
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${soehne.className} w-full`}
|
||||
itemScope
|
||||
itemType='https://schema.org/BlogPosting'
|
||||
>
|
||||
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
@@ -49,98 +59,81 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
|
||||
/>
|
||||
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
<header className='mx-auto max-w-[1000px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
|
||||
<div className='mb-6'>
|
||||
<BackLink />
|
||||
</div>
|
||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||
<div className='relative w-full overflow-hidden rounded-lg'>
|
||||
<Image
|
||||
src={post.ogImage}
|
||||
alt={post.title}
|
||||
width={450}
|
||||
height={360}
|
||||
className='h-auto w-full'
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col justify-between'>
|
||||
<h1
|
||||
className='font-medium text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-6'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<Link
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
>
|
||||
<span itemProp='name'>{a?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
||||
<div className='flex flex-col'>
|
||||
<h1
|
||||
className='font-[430] font-season text-[36px] text-white leading-tight tracking-[-0.02em] sm:text-[48px] md:text-[56px] lg:text-[64px]'
|
||||
itemProp='headline'
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className='mt-4 font-[430] font-season text-[#F6F6F0]/80 text-[16px] leading-[1.5] sm:text-[18px] md:text-[22px]'>
|
||||
{post.description}
|
||||
</p>
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<time
|
||||
className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[1.5] sm:text-[16px]'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
<span className='text-[#F6F6F0]/30'>·</span>
|
||||
{(post.authors || [post.author]).map((a, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
{a?.avatarUrl ? (
|
||||
<Avatar className='size-6'>
|
||||
<AvatarImage src={a.avatarUrl} alt={a.name} />
|
||||
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
<Link
|
||||
href={a?.url || '#'}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer author'
|
||||
className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[1.5] hover:text-[#F6F6F0]/80 sm:text-[16px]'
|
||||
itemProp='author'
|
||||
itemScope
|
||||
itemType='https://schema.org/Person'
|
||||
>
|
||||
<span itemProp='name'>{a?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
||||
</div>
|
||||
</div>
|
||||
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
||||
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
|
||||
<div className='flex flex-shrink-0 items-center gap-4'>
|
||||
<time
|
||||
className='block text-[14px] text-gray-600 leading-[1.5] sm:text-[16px]'
|
||||
dateTime={post.date}
|
||||
itemProp='datePublished'
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<meta itemProp='dateModified' content={post.updated ?? post.date} />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='m-0 block translate-y-[-4px] font-[400] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className='mt-8 border-[#2A2A2A] border-t sm:mt-12' />
|
||||
</header>
|
||||
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
|
||||
<div className='prose prose-lg max-w-none'>
|
||||
<div
|
||||
className='mx-auto max-w-[900px] px-6 py-10 pb-20 sm:px-8 md:px-12'
|
||||
itemProp='articleBody'
|
||||
>
|
||||
<div className={PROSE_CLASSES}>
|
||||
<Article />
|
||||
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{related.length > 0 && (
|
||||
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
|
||||
<h2 className='mb-4 font-medium text-[24px]'>Related posts</h2>
|
||||
<h2 className='mb-4 font-[430] font-season text-[24px] text-white tracking-[-0.02em]'>
|
||||
Related posts
|
||||
</h2>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
|
||||
{related.map((p) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-gray-200'>
|
||||
<div className='overflow-hidden rounded-[10px] border border-[#2A2A2A] bg-[#222222] transition-all hover:border-[#3A3A3A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
@@ -152,14 +145,16 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-gray-600 text-xs'>
|
||||
<div className='mb-1 font-[430] font-season text-[#F6F6F0]/50 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-medium text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[430] font-season text-sm text-white leading-tight'>
|
||||
{p.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -2,64 +2,33 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Share2 } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
|
||||
interface ShareButtonProps {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
export function ShareButton({ url }: ShareButtonProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
setOpen(false)
|
||||
}, 1000)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
setOpen(false)
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareTwitter = () => {
|
||||
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
|
||||
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleShareLinkedIn = () => {
|
||||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
|
||||
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='flex items-center gap-1.5 font-[430] font-season text-[#F6F6F0]/50 text-sm hover:text-[#F6F6F0]/80'
|
||||
aria-label='Copy link'
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
|
||||
aria-label='Share this post'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' minWidth={140}>
|
||||
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
|
||||
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
|
||||
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Share2 className='h-4 w-4' />
|
||||
<span>{copied ? 'Copied!' : 'Share'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -11,8 +10,10 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
const author = posts[0]?.author
|
||||
if (!author) {
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
|
||||
<h1 className='font-medium text-[32px]'>Author not found</h1>
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<h1 className='font-[430] font-season text-[32px] text-white tracking-[-0.02em]'>
|
||||
Author not found
|
||||
</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +26,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
image: author.avatarUrl,
|
||||
}
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
|
||||
@@ -41,12 +42,14 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<h1 className='font-medium text-[32px] leading-tight'>{author.name}</h1>
|
||||
<h1 className='font-[430] font-season text-[32px] text-white leading-tight tracking-[-0.02em]'>
|
||||
{author.name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
|
||||
{posts.map((p) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
|
||||
<div className='overflow-hidden rounded-lg border border-gray-200'>
|
||||
<div className='overflow-hidden rounded-[10px] border border-[#2A2A2A] bg-[#222222] transition-all hover:border-[#3A3A3A]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
@@ -56,14 +59,16 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-gray-600 text-xs'>
|
||||
<div className='mb-1 font-[430] font-season text-[#F6F6F0]/50 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='font-medium text-sm leading-tight'>{p.title}</div>
|
||||
<div className='font-[430] font-season text-sm text-white leading-tight'>
|
||||
{p.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Footer, Nav } from '@/app/(landing)/components'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import { Footer } from '@/app/(landing)/components'
|
||||
|
||||
export default function StudioLayout({ children }: { children: React.ReactNode }) {
|
||||
const orgJsonLd = {
|
||||
@@ -23,7 +26,8 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
<div className={`${season.variable} ${martianMono.variable} relative min-h-screen`}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-[#1C1C1C]' />
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
|
||||
@@ -32,7 +36,7 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<Nav hideAuthButtons={false} variant='landing' />
|
||||
<Navbar />
|
||||
<main className='relative flex-1'>{children}</main>
|
||||
<Footer fullWidth={true} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const revalidate = 3600
|
||||
@@ -29,8 +28,7 @@ export default async function StudioIndex({
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage))
|
||||
const start = (pageNum - 1) * perPage
|
||||
const posts = sorted.slice(start, start + perPage)
|
||||
// Tag filter chips are intentionally disabled for now.
|
||||
// const tags = await getAllTags()
|
||||
|
||||
const studioJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
@@ -40,52 +38,48 @@ export default async function StudioIndex({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`${soehne.className} mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12`}>
|
||||
<div className='relative min-h-screen overflow-hidden'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
|
||||
/>
|
||||
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>Sim Studio</h1>
|
||||
<p className='mb-10 text-[18px] text-gray-700'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
|
||||
{/* Tag filter chips hidden until we have more posts */}
|
||||
{/* <div className='mb-10 flex flex-wrap gap-3'>
|
||||
<Link href='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
|
||||
{tags.map((t) => (
|
||||
<Link key={t.tag} href={`/studio?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
))}
|
||||
</div> */}
|
||||
<main className='relative z-10 mx-auto max-w-[1400px] px-4 py-16 sm:px-6 md:px-8 md:py-24'>
|
||||
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
|
||||
Sim Studio
|
||||
</h1>
|
||||
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Announcements, insights, and guides for building AI agent workflows.
|
||||
</p>
|
||||
|
||||
{/* Grid layout for consistent rows */}
|
||||
<PostGrid posts={posts} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded border px-3 py-1 text-sm'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='text-gray-600 text-sm'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded border px-3 py-1 text-sm'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
<div className='mt-10'>
|
||||
<PostGrid posts={posts} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
{pageNum > 1 && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[14px]'>
|
||||
Page {pageNum} of {totalPages}
|
||||
</span>
|
||||
{pageNum < totalPages && (
|
||||
<Link
|
||||
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
|
||||
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface Author {
|
||||
id: string
|
||||
@@ -22,69 +23,78 @@ interface Post {
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
const INITIAL_VISIBLE = 9
|
||||
|
||||
export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const visiblePosts = showAll ? posts : posts.slice(0, INITIAL_VISIBLE)
|
||||
const hasMore = posts.length > INITIAL_VISIBLE
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div
|
||||
className='grid grid-cols-1 gap-8 md:grid-cols-2 md:gap-10 lg:grid-cols-3'
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
{visiblePosts.map((p, index) => {
|
||||
const authors = p.authors && p.authors.length > 0 ? p.authors : [p.author]
|
||||
const authorNames = authors.map((a) => a?.name).join(', ')
|
||||
const isHovered = hoveredIndex === index
|
||||
const isDimmed = hoveredIndex !== null && !isHovered
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/studio/${p.slug}`}
|
||||
className={cn(
|
||||
'group flex flex-col overflow-hidden rounded-[10px] border border-[#2A2A2A] transition-[background-color] duration-200',
|
||||
isDimmed ? 'bg-transparent' : 'bg-[#222222] hover:border-[#3A3A3A]'
|
||||
)}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden bg-[#1C1C1C]'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-white'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
<div className='flex flex-1 flex-col gap-2 p-4'>
|
||||
<h3 className='font-[430] font-season text-[17px] text-white leading-snug'>
|
||||
{p.title}
|
||||
</h3>
|
||||
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px]'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
<span className='mx-2'>•</span>
|
||||
{authorNames}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && !showAll && (
|
||||
<div className='flex justify-center'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowAll(true)}
|
||||
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-4 py-2 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
53
apps/sim/app/(landing)/studio/studio-blocks.tsx
Normal file
53
apps/sim/app/(landing)/studio/studio-blocks.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BlocksLeftAnimated,
|
||||
BlocksRightAnimated,
|
||||
BlocksRightSideAnimated,
|
||||
BlocksTopLeftAnimated,
|
||||
BlocksTopRightAnimated,
|
||||
useBlockCycle,
|
||||
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||
|
||||
export function StudioBlocks() {
|
||||
const blockStates = useBlockCycle()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksLeftAnimated animState={blockStates.left} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[3vw] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||
>
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,16 +5,21 @@ export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
|
||||
<h1 className='mb-6 font-medium text-[32px] leading-tight'>Browse by tag</h1>
|
||||
<h1 className='mb-6 font-[430] font-season text-[32px] text-white leading-tight tracking-[-0.02em]'>
|
||||
Browse by tag
|
||||
</h1>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Link href='/studio' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
|
||||
<Link
|
||||
href='/studio'
|
||||
className='rounded-full border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.tag}
|
||||
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
|
||||
className='rounded-full border border-gray-300 px-3 py-1 text-sm'
|
||||
className='rounded-full border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
{t.tag} ({t.count})
|
||||
</Link>
|
||||
|
||||
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Martian_Mono } from 'next/font/google'
|
||||
|
||||
/**
|
||||
* Martian Mono font configuration
|
||||
* Monospaced variable font used for code snippets, technical content, and accent text
|
||||
* on the landing page. Supports weights 100-800.
|
||||
*/
|
||||
export const martianMono = Martian_Mono({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-martian-mono',
|
||||
weight: 'variable',
|
||||
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
* @see stores/constants.ts for the source of truth
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 232px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
@@ -93,14 +93,14 @@
|
||||
--border: #e0e0e0; /* primary border */
|
||||
--surface-5: #f3f3f3; /* inputs, form elements */
|
||||
--border-1: #e0e0e0; /* stronger border */
|
||||
--surface-6: #f0f0f0; /* popovers, elevated surfaces */
|
||||
--surface-7: #ececec;
|
||||
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
|
||||
--surface-7: #d9d9d9;
|
||||
|
||||
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
|
||||
|
||||
/* Text - neutral */
|
||||
--text-primary: #2d2d2d;
|
||||
--text-secondary: #404040;
|
||||
--text-secondary: #4e4e4e;
|
||||
--text-tertiary: #5c5c5c;
|
||||
--text-muted: #737373;
|
||||
--text-subtle: #8c8c8c;
|
||||
@@ -125,7 +125,7 @@
|
||||
|
||||
/* Font weights - lighter for light mode */
|
||||
--font-weight-base: 430;
|
||||
--font-weight-medium: 450;
|
||||
--font-weight-medium: 440;
|
||||
--font-weight-semibold: 500;
|
||||
|
||||
/* Extended palette */
|
||||
@@ -211,7 +211,7 @@
|
||||
--surface-5: #363636;
|
||||
--border-1: #3d3d3d;
|
||||
--surface-6: #454545;
|
||||
--surface-7: #454545;
|
||||
--surface-7: #505050;
|
||||
|
||||
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const UpdateCostSchema = z.object({
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
inputTokens: z.number().min(0).default(0),
|
||||
outputTokens: z.number().min(0).default(0),
|
||||
source: z.enum(['copilot', 'mcp_copilot']).default('copilot'),
|
||||
source: z.enum(['copilot', 'workspace-chat', 'mcp_copilot']).default('copilot'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -98,19 +98,22 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
totalCost: sql`total_cost + ${cost}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${cost}`,
|
||||
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
|
||||
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
// Also increment MCP-specific counters when source is mcp_copilot
|
||||
if (isMcp) {
|
||||
updateFields.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
|
||||
updateFields.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
|
||||
updateFields.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
|
||||
}
|
||||
|
||||
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
|
||||
@@ -121,10 +124,10 @@ export async function POST(req: NextRequest) {
|
||||
source,
|
||||
})
|
||||
|
||||
// Log usage for complete audit trail
|
||||
// Log usage for complete audit trail with the original source for visibility
|
||||
await logModelUsage({
|
||||
userId,
|
||||
source: isMcp ? 'mcp_copilot' : 'copilot',
|
||||
source,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
|
||||
/** Headers for server-to-server calls to the Go copilot backend. */
|
||||
function copilotHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Fetch user's auto-allowed integration tools
|
||||
*/
|
||||
@@ -20,24 +30,18 @@ export async function GET() {
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [userSettings] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, userId))
|
||||
.limit(1)
|
||||
const res = await fetch(
|
||||
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`,
|
||||
{ method: 'GET', headers: copilotHeaders() }
|
||||
)
|
||||
|
||||
if (userSettings) {
|
||||
const autoAllowedTools = (userSettings.copilotAutoAllowedTools as string[]) || []
|
||||
return NextResponse.json({ autoAllowedTools })
|
||||
if (!res.ok) {
|
||||
logger.warn('Go backend returned error for list auto-allowed', { status: res.status })
|
||||
return NextResponse.json({ autoAllowedTools: [] })
|
||||
}
|
||||
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotAutoAllowedTools: [],
|
||||
})
|
||||
|
||||
return NextResponse.json({ autoAllowedTools: [] })
|
||||
const payload = await res.json()
|
||||
return NextResponse.json({ autoAllowedTools: payload?.autoAllowedTools || [] })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch auto-allowed tools', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
@@ -62,38 +66,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 })
|
||||
}
|
||||
|
||||
const toolId = body.toolId
|
||||
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
|
||||
if (!currentTools.includes(toolId)) {
|
||||
const updatedTools = [...currentTools, toolId]
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
copilotAutoAllowedTools: updatedTools,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
|
||||
logger.info('Added tool to auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
|
||||
}
|
||||
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotAutoAllowedTools: [toolId],
|
||||
const res = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
|
||||
method: 'POST',
|
||||
headers: copilotHeaders(),
|
||||
body: JSON.stringify({ userId, toolId: body.toolId }),
|
||||
})
|
||||
|
||||
logger.info('Created settings and added tool to auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: [toolId] })
|
||||
if (!res.ok) {
|
||||
logger.warn('Go backend returned error for add auto-allowed', { status: res.status })
|
||||
return NextResponse.json({ error: 'Failed to add tool' }, { status: 500 })
|
||||
}
|
||||
|
||||
const payload = await res.json()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
autoAllowedTools: payload?.autoAllowedTools || [],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to add auto-allowed tool', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
@@ -119,25 +107,21 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
const res = await fetch(
|
||||
`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`,
|
||||
{ method: 'DELETE', headers: copilotHeaders() }
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
const updatedTools = currentTools.filter((t) => t !== toolId)
|
||||
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
copilotAutoAllowedTools: updatedTools,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
|
||||
logger.info('Removed tool from auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
|
||||
if (!res.ok) {
|
||||
logger.warn('Go backend returned error for remove auto-allowed', { status: res.status })
|
||||
return NextResponse.json({ error: 'Failed to remove tool' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, autoAllowedTools: [] })
|
||||
const payload = await res.json()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
autoAllowedTools: payload?.autoAllowedTools || [],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove auto-allowed tool', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -5,17 +5,15 @@ import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { buildConversationHistory } from '@/lib/copilot/chat-context'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import {
|
||||
createSSEStream,
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import {
|
||||
createStreamEventWriter,
|
||||
resetStreamBuffer,
|
||||
setStreamMeta,
|
||||
} from '@/lib/copilot/orchestrator/stream-buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -23,54 +21,10 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
async function requestChatTitleFromCopilot(params: {
|
||||
message: string
|
||||
model: string
|
||||
provider?: string
|
||||
}): Promise<string | null> {
|
||||
const { message, model, provider } = params
|
||||
if (!message || !model) return null
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model,
|
||||
...(provider ? { provider } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to generate chat title via copilot backend', {
|
||||
status: response.status,
|
||||
error: payload,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
return title || null
|
||||
} catch (error) {
|
||||
logger.error('Error generating chat title:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
@@ -81,9 +35,10 @@ const FileAttachmentSchema = z.object({
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
model: z.string().optional().default('claude-opus-4-5'),
|
||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||
@@ -93,7 +48,6 @@ const ChatMessageSchema = z.object({
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -116,7 +70,6 @@ const ChatMessageSchema = z.object({
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
// For workflow_block, provide both workflowId and blockId
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -155,7 +108,6 @@ export async function POST(req: NextRequest) {
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
provider,
|
||||
conversationId,
|
||||
contexts,
|
||||
commands,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
@@ -174,7 +126,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
: contexts
|
||||
|
||||
// Resolve workflowId - if not provided, use first workflow or find by name
|
||||
// Copilot route always requires a workflow scope
|
||||
const resolved = await resolveWorkflowIdForUser(
|
||||
authenticatedUserId,
|
||||
providedWorkflowId,
|
||||
@@ -186,11 +138,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
const workflowId = resolved.workflowId
|
||||
const workflowResolvedName = resolved.workflowName
|
||||
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||
workflowId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
@@ -204,7 +157,7 @@ export async function POST(req: NextRequest) {
|
||||
: undefined,
|
||||
})
|
||||
} catch {}
|
||||
// Preprocess contexts server-side
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
|
||||
try {
|
||||
@@ -234,7 +187,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
@@ -249,27 +201,23 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
actualChatId = chatResult.chatId || chatId
|
||||
const history = buildConversationHistory(
|
||||
chatResult.conversationHistory,
|
||||
(chatResult.chat?.conversationId as string | undefined) || conversationId
|
||||
)
|
||||
conversationHistory = history.history
|
||||
conversationHistory = Array.isArray(chatResult.conversationHistory)
|
||||
? chatResult.conversationHistory
|
||||
: []
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
workflowId,
|
||||
workflowId: workflowId || '',
|
||||
workflowName: workflowResolvedName,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
mode,
|
||||
model: selectedModel,
|
||||
provider,
|
||||
conversationId: effectiveConversationId,
|
||||
conversationHistory,
|
||||
contexts: agentContexts,
|
||||
fileAttachments,
|
||||
@@ -287,7 +235,6 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
|
||||
messageLength: message.length,
|
||||
mode: effectiveMode,
|
||||
@@ -302,134 +249,35 @@ export async function POST(req: NextRequest) {
|
||||
} catch {}
|
||||
|
||||
if (stream) {
|
||||
const streamId = userMessageIdToUse
|
||||
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
|
||||
let clientDisconnected = false
|
||||
const transformedStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
await resetStreamBuffer(streamId)
|
||||
await setStreamMeta(streamId, { status: 'active', userId: authenticatedUserId })
|
||||
eventWriter = createStreamEventWriter(streamId)
|
||||
|
||||
const shouldFlushEvent = (event: Record<string, any>) =>
|
||||
event.type === 'tool_call' ||
|
||||
event.type === 'tool_result' ||
|
||||
event.type === 'tool_error' ||
|
||||
event.type === 'subagent_end' ||
|
||||
event.type === 'structured_result' ||
|
||||
event.type === 'subagent_result' ||
|
||||
event.type === 'done' ||
|
||||
event.type === 'error'
|
||||
|
||||
const pushEvent = async (event: Record<string, any>) => {
|
||||
if (!eventWriter) return
|
||||
const entry = await eventWriter.write(event)
|
||||
if (shouldFlushEvent(event)) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
const payload = {
|
||||
...event,
|
||||
eventId: entry.eventId,
|
||||
streamId,
|
||||
}
|
||||
try {
|
||||
if (!clientDisconnected) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
|
||||
}
|
||||
} catch {
|
||||
clientDisconnected = true
|
||||
await eventWriter.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
await pushEvent({ type: 'chat_id', chatId: actualChatId })
|
||||
}
|
||||
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
await pushEvent({ type: 'title_updated', title })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
onEvent: async (event) => {
|
||||
await pushEvent(event)
|
||||
},
|
||||
})
|
||||
|
||||
if (currentChat && result.conversationId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: result.conversationId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, { status: 'complete', userId: authenticatedUserId })
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Orchestration error:`, error)
|
||||
await eventWriter.close()
|
||||
await setStreamMeta(streamId, {
|
||||
status: 'error',
|
||||
userId: authenticatedUserId,
|
||||
error: error instanceof Error ? error.message : 'Stream error',
|
||||
})
|
||||
await pushEvent({
|
||||
type: 'error',
|
||||
data: {
|
||||
displayMessage: 'An unexpected error occurred while processing the response.',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
clientDisconnected = true
|
||||
if (eventWriter) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
const sseStream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
streamId: userMessageIdToUse,
|
||||
chatId: actualChatId,
|
||||
currentChat,
|
||||
conversationHistory,
|
||||
message,
|
||||
titleModel: selectedModel,
|
||||
titleProvider: provider,
|
||||
requestId: tracker.requestId,
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(transformedStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
})
|
||||
@@ -481,7 +329,7 @@ export async function POST(req: NextRequest) {
|
||||
// Start title generation in parallel if this is first message (non-streaming)
|
||||
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
|
||||
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
|
||||
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
|
||||
requestChatTitle({ message, model: selectedModel, provider })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
@@ -505,9 +353,6 @@ export async function POST(req: NextRequest) {
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(nonStreamingResult.conversationId
|
||||
? { conversationId: nonStreamingResult.conversationId }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
@@ -559,16 +404,15 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const chatId = searchParams.get('chatId')
|
||||
|
||||
// Get authenticated user using consolidated helper
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// If chatId is provided, fetch a single chat
|
||||
if (chatId) {
|
||||
const [chat] = await db
|
||||
.select({
|
||||
@@ -605,11 +449,14 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return createBadRequestResponse('workflowId or chatId is required')
|
||||
if (!workflowId && !workspaceId) {
|
||||
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
|
||||
}
|
||||
|
||||
// Fetch chats for this user and workflow
|
||||
const scopeFilter = workflowId
|
||||
? eq(copilotChats.workflowId, workflowId)
|
||||
: eq(copilotChats.workspaceId, workspaceId!)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
@@ -622,12 +469,9 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId))
|
||||
)
|
||||
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
// Transform the data to include message count
|
||||
const transformedChats = chats.map((chat) => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -640,7 +484,8 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: chat.updatedAt,
|
||||
}))
|
||||
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`)
|
||||
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -20,7 +20,8 @@ const ConfirmationSchema = z.object({
|
||||
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background'] as const, {
|
||||
errorMap: () => ({ message: 'Invalid notification status' }),
|
||||
}),
|
||||
message: z.string().optional(), // Optional message for background moves or additional context
|
||||
message: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,8 @@ const ConfirmationSchema = z.object({
|
||||
async function updateToolCallStatus(
|
||||
toolCallId: string,
|
||||
status: NotificationStatus,
|
||||
message?: string
|
||||
message?: string,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
@@ -40,11 +42,14 @@ async function updateToolCallStatus(
|
||||
|
||||
try {
|
||||
const key = `${REDIS_TOOL_CALL_PREFIX}${toolCallId}`
|
||||
const payload = {
|
||||
const payload: Record<string, unknown> = {
|
||||
status,
|
||||
message: message || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
if (data) {
|
||||
payload.data = data
|
||||
}
|
||||
await redis.set(key, JSON.stringify(payload), 'EX', REDIS_TOOL_CALL_TTL_SECONDS)
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -74,10 +79,10 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
|
||||
const { toolCallId, status, message, data } = ConfirmationSchema.parse(body)
|
||||
|
||||
// Update the tool call status in Redis
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message)
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message, data)
|
||||
|
||||
if (!updated) {
|
||||
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
|
||||
|
||||
@@ -18,11 +18,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
ORCHESTRATION_TIMEOUT_MS,
|
||||
SIM_AGENT_API_URL,
|
||||
SIM_AGENT_VERSION,
|
||||
} from '@/lib/copilot/constants'
|
||||
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
|
||||
import {
|
||||
@@ -724,16 +720,14 @@ async function handleBuildToolCall(
|
||||
mode: 'agent',
|
||||
commands: ['fast'],
|
||||
messageId: randomUUID(),
|
||||
version: SIM_AGENT_VERSION,
|
||||
headless: true,
|
||||
chatId,
|
||||
source: 'mcp',
|
||||
}
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: true,
|
||||
timeout: 300000,
|
||||
interactive: false,
|
||||
|
||||
166
apps/sim/app/api/mothership/chat/route.ts
Normal file
166
apps/sim/app/api/mothership/chat/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
|
||||
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
|
||||
|
||||
const logger = createLogger('MothershipChatAPI')
|
||||
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
|
||||
const MothershipMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.enum([
|
||||
'past_chat',
|
||||
'workflow',
|
||||
'current_workflow',
|
||||
'blocks',
|
||||
'logs',
|
||||
'workflow_block',
|
||||
'knowledge',
|
||||
'templates',
|
||||
'docs',
|
||||
]),
|
||||
label: z.string(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
knowledgeId: z.string().optional(),
|
||||
blockId: z.string().optional(),
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/chat
|
||||
* Workspace-scoped chat — no workflowId, proxies to Go /api/mothership.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const authenticatedUserId = session.user.id
|
||||
const body = await req.json()
|
||||
const {
|
||||
message,
|
||||
workspaceId,
|
||||
userMessageId: providedMessageId,
|
||||
chatId,
|
||||
createNewChat,
|
||||
fileAttachments,
|
||||
contexts,
|
||||
} = MothershipMessageSchema.parse(body)
|
||||
|
||||
const userMessageId = providedMessageId || crypto.randomUUID()
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(contexts) && contexts.length > 0) {
|
||||
try {
|
||||
const { processContextsServer } = await import('@/lib/copilot/process-contents')
|
||||
agentContexts = await processContextsServer(contexts as any, authenticatedUserId, message)
|
||||
} catch (e) {
|
||||
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
|
||||
}
|
||||
}
|
||||
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
|
||||
if (chatId || createNewChat) {
|
||||
const chatResult = await resolveOrCreateChat({
|
||||
chatId,
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
model: 'claude-opus-4-5',
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
actualChatId = chatResult.chatId || chatId
|
||||
conversationHistory = Array.isArray(chatResult.conversationHistory)
|
||||
? chatResult.conversationHistory
|
||||
: []
|
||||
}
|
||||
|
||||
const workspaceContext = await generateWorkspaceContext(workspaceId, authenticatedUserId)
|
||||
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId,
|
||||
mode: 'agent',
|
||||
model: '',
|
||||
conversationHistory,
|
||||
contexts: agentContexts,
|
||||
fileAttachments,
|
||||
chatId: actualChatId,
|
||||
workspaceContext,
|
||||
},
|
||||
{ selectedModel: '' }
|
||||
)
|
||||
|
||||
const stream = createSSEStream({
|
||||
requestPayload,
|
||||
userId: authenticatedUserId,
|
||||
streamId: userMessageId,
|
||||
chatId: actualChatId,
|
||||
currentChat,
|
||||
conversationHistory,
|
||||
message,
|
||||
titleModel: 'claude-opus-4-5',
|
||||
requestId: tracker.requestId,
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
chatId: actualChatId,
|
||||
goRoute: '/api/mothership',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${tracker.requestId}] Error handling mothership chat:`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
@@ -75,8 +74,6 @@ export async function POST(req: NextRequest) {
|
||||
model: selectedModel,
|
||||
mode: transportMode,
|
||||
messageId: crypto.randomUUID(),
|
||||
version: SIM_AGENT_VERSION,
|
||||
headless: true,
|
||||
chatId,
|
||||
}
|
||||
|
||||
@@ -84,6 +81,7 @@ export async function POST(req: NextRequest) {
|
||||
userId: auth.userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: parsed.autoExecuteTools,
|
||||
timeout: parsed.timeout,
|
||||
interactive: false,
|
||||
@@ -93,8 +91,7 @@ export async function POST(req: NextRequest) {
|
||||
success: result.success,
|
||||
content: result.content,
|
||||
toolCalls: result.toolCalls,
|
||||
chatId: result.chatId || chatId, // Return the chatId for conversation continuity
|
||||
conversationId: result.conversationId,
|
||||
chatId: result.chatId || chatId,
|
||||
error: result.error,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -108,6 +111,49 @@ const WorkflowStateSchema = z.object({
|
||||
variables: z.any().optional(), // Workflow variables
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/workflows/[id]/state
|
||||
* Fetch the current workflow state from normalized tables.
|
||||
* Used by the client after server-side edits (edit_workflow) to stay in sync.
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
blocks: normalized.blocks,
|
||||
edges: normalized.edges,
|
||||
loops: normalized.loops || {},
|
||||
parallels: normalized.parallels || {},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflow state', {
|
||||
workflowId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/workflows/[id]/state
|
||||
* Save complete workflow state to normalized database tables
|
||||
|
||||
2474
apps/sim/app/changelog/components/changelog-blocks.tsx
Normal file
2474
apps/sim/app/changelog/components/changelog-blocks.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedLink } from '@/app/changelog/components/branded-link'
|
||||
import { ChangelogBlocks } from '@/app/changelog/components/changelog-blocks'
|
||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||
import { Breaks } from '@/app/changelog/components/variants/breaks'
|
||||
import { Scroll } from '@/app/changelog/components/variants/scroll'
|
||||
|
||||
export interface ChangelogEntry {
|
||||
tag: string
|
||||
@@ -14,77 +15,142 @@ export interface ChangelogEntry {
|
||||
contributors?: string[]
|
||||
}
|
||||
|
||||
export type ChangelogVariant = 'breaks' | 'scroll'
|
||||
|
||||
const VARIANTS = {
|
||||
breaks: Breaks,
|
||||
scroll: Scroll,
|
||||
} as const
|
||||
|
||||
function extractMentions(body: string): string[] {
|
||||
const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? []
|
||||
const uniq = Array.from(new Set(matches.map((m) => m.slice(1))))
|
||||
return uniq
|
||||
}
|
||||
|
||||
export default async function ChangelogContent() {
|
||||
interface ChangelogContentProps {
|
||||
variant?: ChangelogVariant
|
||||
}
|
||||
|
||||
const RELEASES_PER_PAGE = 100
|
||||
const MAX_RELEASE_PAGES = 10
|
||||
|
||||
interface GitHubRelease {
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
function mapReleasesToEntries(releases: GitHubRelease[]): ChangelogEntry[] {
|
||||
return releases
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => ({
|
||||
tag: release.tag_name,
|
||||
title: release.name || release.tag_name,
|
||||
content: String(release.body || ''),
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
contributors: extractMentions(String(release.body || '')),
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function ChangelogContent({ variant }: ChangelogContentProps) {
|
||||
let entries: ChangelogEntry[] = []
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/releases?per_page=10&page=1',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
const releases: any[] = await res.json()
|
||||
entries = (releases || [])
|
||||
.filter((r) => !r.prerelease)
|
||||
.map((r) => ({
|
||||
tag: r.tag_name,
|
||||
title: r.name || r.tag_name,
|
||||
content: String(r.body || ''),
|
||||
date: r.published_at,
|
||||
url: r.html_url,
|
||||
contributors: extractMentions(String(r.body || '')),
|
||||
}))
|
||||
const allReleases: GitHubRelease[] = []
|
||||
|
||||
for (let page = 1; page <= MAX_RELEASE_PAGES; page += 1) {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/simstudioai/sim/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`,
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) break
|
||||
|
||||
const releases: GitHubRelease[] = await res.json()
|
||||
if (!Array.isArray(releases) || releases.length === 0) break
|
||||
|
||||
allReleases.push(...releases)
|
||||
|
||||
if (releases.length < RELEASES_PER_PAGE) break
|
||||
}
|
||||
|
||||
entries = mapReleasesToEntries(allReleases)
|
||||
} catch (err) {
|
||||
entries = []
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<div className='relative grid md:grid-cols-2'>
|
||||
{/* Left intro panel */}
|
||||
<div className='relative top-0 overflow-hidden border-border border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
<div className='absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.06]' />
|
||||
<div className='absolute inset-0 bg-gradient-to-tr from-background via-transparent to-background/60' />
|
||||
if (variant) {
|
||||
const Layout = VARIANTS[variant]
|
||||
return <Layout entries={entries} />
|
||||
}
|
||||
|
||||
<div className='relative mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<h1
|
||||
className={`${soehne.className} mt-6 font-semibold text-4xl tracking-tight sm:text-5xl`}
|
||||
>
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C]'>
|
||||
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
|
||||
{/* Left intro panel */}
|
||||
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
{/* Background card decoration */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
{/* Union decorative shape */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
|
||||
>
|
||||
<Image
|
||||
src='/landing/union-right.svg'
|
||||
alt=''
|
||||
width={768}
|
||||
height={768}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Animated colored block decorations */}
|
||||
<ChangelogBlocks />
|
||||
|
||||
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className={`${inter.className} mt-4 text-muted-foreground text-sm`}>
|
||||
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
|
||||
changes are documented here with detailed release notes.
|
||||
</p>
|
||||
<hr className='mt-6 border-border' />
|
||||
|
||||
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
||||
<BrandedLink
|
||||
<div className='mt-5 flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
<Github className='h-4 w-4' />
|
||||
View on GitHub
|
||||
</BrandedLink>
|
||||
</Link>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<BookOpen className='h-4 w-4' />
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog.xml'
|
||||
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<Rss className='h-4 w-4' />
|
||||
RSS Feed
|
||||
@@ -95,7 +161,7 @@ export default async function ChangelogContent() {
|
||||
|
||||
{/* Right timeline */}
|
||||
<div className='relative px-4 py-10 sm:px-6 md:px-8 md:py-12'>
|
||||
<div className='relative max-w-2xl pl-8'>
|
||||
<div className='relative max-w-2xl'>
|
||||
<ChangelogList initialEntries={entries} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
414
apps/sim/app/changelog/components/commit-heatmap.tsx
Normal file
414
apps/sim/app/changelog/components/commit-heatmap.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
|
||||
|
||||
const HEATMAP_COLORS = ['#2ABBF8', '#00F701', '#FFCC02', '#FA4EDF'] as const
|
||||
const GREEN_BASE = '#39d353'
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const
|
||||
|
||||
const CELL_SIZE = 10
|
||||
const CELL_GAP = 3
|
||||
const CELL_RADIUS = 2
|
||||
const DAY_LABEL_WIDTH = 28
|
||||
const MONTH_ROW_HEIGHT = 16
|
||||
const MAX_HEATMAP_WEEKS = 26
|
||||
|
||||
/** Cells (days) from the active date to the edge of the colored glow. */
|
||||
const GLOW_RADIUS = 7
|
||||
|
||||
const EASING_K = 5
|
||||
const SNAP_CELLS = 10
|
||||
const SETTLE_THRESHOLD = 0.05
|
||||
|
||||
interface CellData {
|
||||
date: string
|
||||
weekIndex: number
|
||||
dayIndex: number
|
||||
seqIndex: number
|
||||
traversalIndex: number
|
||||
}
|
||||
|
||||
interface GridData {
|
||||
cells: CellData[]
|
||||
dates: Date[]
|
||||
dateToSeqIndex: Map<string, number>
|
||||
monthLabels: Array<{ label: string; weekIndex: number }>
|
||||
dayLabels: Array<{ label: string; dayIndex: number }>
|
||||
numWeeks: number
|
||||
svgWidth: number
|
||||
svgHeight: number
|
||||
}
|
||||
|
||||
function formatDateToISO(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function greenOpacity(seed: number): number {
|
||||
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453
|
||||
return 0.06 + (x - Math.floor(x)) * 0.2
|
||||
}
|
||||
|
||||
function pseudoRandom(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
function cellPixel(cell: CellData): [number, number] {
|
||||
return [
|
||||
DAY_LABEL_WIDTH + cell.weekIndex * (CELL_SIZE + CELL_GAP),
|
||||
MONTH_ROW_HEIGHT + cell.dayIndex * (CELL_SIZE + CELL_GAP),
|
||||
]
|
||||
}
|
||||
|
||||
function computeGrid(entries: ChangelogEntry[]): GridData | null {
|
||||
if (!entries.length) return null
|
||||
const earliestEntry = entries.reduce((earliest, entry) => {
|
||||
const d = entry.date.split('T')[0]
|
||||
return d < earliest ? d : earliest
|
||||
}, entries[0].date.split('T')[0])
|
||||
const earliestDate = new Date(`${earliestEntry}T00:00:00`)
|
||||
earliestDate.setDate(earliestDate.getDate() - earliestDate.getDay())
|
||||
|
||||
const now = new Date()
|
||||
const endSaturday = new Date(now)
|
||||
endSaturday.setDate(endSaturday.getDate() + (6 - endSaturday.getDay()))
|
||||
const maxWindowStart = new Date(endSaturday)
|
||||
maxWindowStart.setDate(maxWindowStart.getDate() - (MAX_HEATMAP_WEEKS * 7 - 1))
|
||||
const startDate = earliestDate > maxWindowStart ? earliestDate : maxWindowStart
|
||||
|
||||
const dates: Date[] = []
|
||||
const current = new Date(startDate)
|
||||
while (current <= endSaturday) {
|
||||
dates.push(new Date(current))
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
const numWeeks = Math.ceil(dates.length / 7)
|
||||
const cells: CellData[] = dates.map((date, seqIndex) => {
|
||||
const reverseSeq = dates.length - 1 - seqIndex
|
||||
const columnFromRight = Math.floor(reverseSeq / 7)
|
||||
const rowInColumn = reverseSeq % 7
|
||||
const goingDown = columnFromRight % 2 === 0
|
||||
const dayIndex = goingDown ? rowInColumn : 6 - rowInColumn
|
||||
const weekIndex = numWeeks - 1 - columnFromRight
|
||||
return {
|
||||
date: formatDateToISO(date),
|
||||
weekIndex,
|
||||
dayIndex,
|
||||
seqIndex,
|
||||
traversalIndex: reverseSeq,
|
||||
}
|
||||
})
|
||||
|
||||
const earliestCellByWeek = new Map<number, CellData>()
|
||||
for (const cell of cells) {
|
||||
const existing = earliestCellByWeek.get(cell.weekIndex)
|
||||
if (!existing || cell.seqIndex < existing.seqIndex) {
|
||||
earliestCellByWeek.set(cell.weekIndex, cell)
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_LABEL_GAP_WEEKS = 3
|
||||
const rawLabels: Array<{ label: string; weekIndex: number }> = []
|
||||
let prevMonth = -1
|
||||
for (let w = 0; w < numWeeks; w++) {
|
||||
const earliest = earliestCellByWeek.get(w)
|
||||
if (!earliest) continue
|
||||
const month = new Date(`${earliest.date}T00:00:00`).getMonth()
|
||||
if (month !== prevMonth) {
|
||||
rawLabels.push({ label: MONTH_NAMES[month], weekIndex: w })
|
||||
prevMonth = month
|
||||
}
|
||||
}
|
||||
|
||||
const monthLabels: Array<{ label: string; weekIndex: number }> = []
|
||||
for (let i = 0; i < rawLabels.length; i++) {
|
||||
const cur = rawLabels[i]
|
||||
const next = rawLabels[i + 1]
|
||||
if (next && next.weekIndex - cur.weekIndex < MIN_LABEL_GAP_WEEKS) continue
|
||||
if (
|
||||
monthLabels.length > 0 &&
|
||||
cur.weekIndex - monthLabels[monthLabels.length - 1].weekIndex < MIN_LABEL_GAP_WEEKS
|
||||
) {
|
||||
continue
|
||||
}
|
||||
monthLabels.push(cur)
|
||||
}
|
||||
|
||||
const EST_LABEL_WIDTH = 24
|
||||
while (
|
||||
monthLabels.length > 0 &&
|
||||
DAY_LABEL_WIDTH +
|
||||
monthLabels[monthLabels.length - 1].weekIndex * (CELL_SIZE + CELL_GAP) +
|
||||
EST_LABEL_WIDTH >
|
||||
DAY_LABEL_WIDTH + numWeeks * (CELL_SIZE + CELL_GAP) - CELL_GAP
|
||||
) {
|
||||
monthLabels.pop()
|
||||
}
|
||||
|
||||
const dateToSeqIndex = new Map<string, number>()
|
||||
for (const cell of cells) {
|
||||
dateToSeqIndex.set(cell.date, cell.seqIndex)
|
||||
}
|
||||
|
||||
const gridWidth = numWeeks * (CELL_SIZE + CELL_GAP) - CELL_GAP
|
||||
const gridHeight = 7 * (CELL_SIZE + CELL_GAP) - CELL_GAP
|
||||
const svgWidth = DAY_LABEL_WIDTH + gridWidth + 16
|
||||
const svgHeight = MONTH_ROW_HEIGHT + gridHeight
|
||||
|
||||
return {
|
||||
cells,
|
||||
dates,
|
||||
dateToSeqIndex,
|
||||
monthLabels,
|
||||
dayLabels: [
|
||||
{ label: 'Mon', dayIndex: 1 },
|
||||
{ label: 'Wed', dayIndex: 3 },
|
||||
{ label: 'Fri', dayIndex: 5 },
|
||||
],
|
||||
numWeeks,
|
||||
svgWidth,
|
||||
svgHeight,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommitHeatmapHandle {
|
||||
setActiveDate: (date: string) => void
|
||||
}
|
||||
|
||||
interface CommitHeatmapProps {
|
||||
entries: ChangelogEntry[]
|
||||
activeDate?: string
|
||||
}
|
||||
|
||||
export const CommitHeatmap = forwardRef<CommitHeatmapHandle, CommitHeatmapProps>(
|
||||
function CommitHeatmap({ entries, activeDate: activeDateProp }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const gridRef = useRef<GridData | null>(null)
|
||||
const targetRef = useRef(-1)
|
||||
const currentRef = useRef(-1)
|
||||
const rafRef = useRef(0)
|
||||
const lastTsRef = useRef(0)
|
||||
const runningRef = useRef(false)
|
||||
|
||||
const grid = useMemo(() => computeGrid(entries), [entries])
|
||||
gridRef.current = grid
|
||||
|
||||
const resolveSeqIndex = useCallback((date: string): number => {
|
||||
const g = gridRef.current
|
||||
if (!g || g.cells.length === 0) return -1
|
||||
const idx = g.dateToSeqIndex.get(date)
|
||||
if (idx !== undefined) return idx
|
||||
const first = g.cells[0].date
|
||||
const last = g.cells[g.cells.length - 1].date
|
||||
if (date < first) return 0
|
||||
if (date > last) return g.cells.length - 1
|
||||
return -1
|
||||
}, [])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const g = gridRef.current
|
||||
if (!canvas || !g) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const { cells, monthLabels, dayLabels, svgWidth, svgHeight } = g
|
||||
const activeSeq = currentRef.current
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = Math.round(rect.width * dpr)
|
||||
const h = Math.round(rect.height * dpr)
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
}
|
||||
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const scaleX = w / svgWidth
|
||||
const scaleY = h / svgHeight
|
||||
const scale = Math.min(scaleX, scaleY)
|
||||
const tx = (w - svgWidth * scale) / 2
|
||||
const ty = (h - svgHeight * scale) / 2
|
||||
ctx.setTransform(scale, 0, 0, scale, tx, ty)
|
||||
|
||||
ctx.fillStyle = 'rgba(246,246,246,0.4)'
|
||||
ctx.font = '9px var(--font-season), system-ui, sans-serif'
|
||||
ctx.textBaseline = 'middle'
|
||||
for (const { label, weekIndex: wi } of monthLabels) {
|
||||
ctx.fillText(label, DAY_LABEL_WIDTH + wi * (CELL_SIZE + CELL_GAP), 11)
|
||||
}
|
||||
for (const { label, dayIndex } of dayLabels) {
|
||||
const y = MONTH_ROW_HEIGHT + dayIndex * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1
|
||||
ctx.fillText(label, 4, y)
|
||||
}
|
||||
|
||||
const fillRR = (rx: number, ry: number, rw: number, rh: number, r: number) => {
|
||||
ctx.beginPath()
|
||||
if (typeof ctx.roundRect === 'function') {
|
||||
ctx.roundRect(rx, ry, rw, rh, r)
|
||||
} else {
|
||||
ctx.moveTo(rx + r, ry)
|
||||
ctx.lineTo(rx + rw - r, ry)
|
||||
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + r)
|
||||
ctx.lineTo(rx + rw, ry + rh - r)
|
||||
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - r, ry + rh)
|
||||
ctx.lineTo(rx + r, ry + rh)
|
||||
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - r)
|
||||
ctx.lineTo(rx, ry + r)
|
||||
ctx.quadraticCurveTo(rx, ry, rx + r, ry)
|
||||
}
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
for (const cell of cells) {
|
||||
const [x, y] = cellPixel(cell)
|
||||
|
||||
ctx.fillStyle = GREEN_BASE
|
||||
ctx.globalAlpha = greenOpacity(cell.seqIndex)
|
||||
fillRR(x, y, CELL_SIZE, CELL_SIZE, CELL_RADIUS)
|
||||
|
||||
if (activeSeq >= 0) {
|
||||
const dist = Math.abs(cell.seqIndex - activeSeq)
|
||||
if (dist < GLOW_RADIUS) {
|
||||
const t = 1 - dist / GLOW_RADIUS
|
||||
const smooth = t * t * (3 - 2 * t)
|
||||
const colorIdx = Math.floor(pseudoRandom(cell.seqIndex) * HEATMAP_COLORS.length)
|
||||
const baseOpacity = 0.6 + pseudoRandom(cell.seqIndex + 100) * 0.4
|
||||
const overlay = smooth * baseOpacity
|
||||
if (overlay > 0.01) {
|
||||
ctx.fillStyle = HEATMAP_COLORS[colorIdx]
|
||||
ctx.globalAlpha = overlay
|
||||
fillRR(x, y, CELL_SIZE, CELL_SIZE, CELL_RADIUS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}, [])
|
||||
|
||||
const animate = useCallback(
|
||||
(timestamp: number) => {
|
||||
const g = gridRef.current
|
||||
if (!g) {
|
||||
runningRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const target = targetRef.current
|
||||
const current = currentRef.current
|
||||
|
||||
if (target < 0) {
|
||||
currentRef.current = target
|
||||
draw()
|
||||
runningRef.current = false
|
||||
lastTsRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const delta = target - current
|
||||
const cellDelta = Math.abs(delta)
|
||||
|
||||
if (cellDelta < SETTLE_THRESHOLD) {
|
||||
currentRef.current = target
|
||||
draw()
|
||||
runningRef.current = false
|
||||
lastTsRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const dt =
|
||||
lastTsRef.current > 0 ? Math.min((timestamp - lastTsRef.current) / 1000, 0.1) : 1 / 60
|
||||
lastTsRef.current = timestamp
|
||||
|
||||
const k = cellDelta > SNAP_CELLS ? 20 : EASING_K
|
||||
const factor = 1 - Math.exp(-k * dt)
|
||||
currentRef.current = current + delta * factor
|
||||
|
||||
draw()
|
||||
rafRef.current = requestAnimationFrame(animate)
|
||||
},
|
||||
[draw]
|
||||
)
|
||||
|
||||
const startLoop = useCallback(() => {
|
||||
if (runningRef.current) return
|
||||
runningRef.current = true
|
||||
lastTsRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(animate)
|
||||
}, [animate])
|
||||
|
||||
const setActiveDate = useCallback(
|
||||
(date: string) => {
|
||||
const seqIdx = resolveSeqIndex(date)
|
||||
if (seqIdx < 0) return
|
||||
targetRef.current = seqIdx
|
||||
if (currentRef.current < 0) currentRef.current = seqIdx
|
||||
startLoop()
|
||||
},
|
||||
[resolveSeqIndex, startLoop]
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({ setActiveDate }), [setActiveDate])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDateProp) setActiveDate(activeDateProp)
|
||||
}, [activeDateProp, setActiveDate])
|
||||
|
||||
useEffect(() => {
|
||||
if (grid) draw()
|
||||
}, [grid, draw])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !grid) return
|
||||
const ro = new ResizeObserver(() => draw())
|
||||
ro.observe(canvas)
|
||||
return () => ro.disconnect()
|
||||
}, [grid, draw])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (!entries.length || !grid) return null
|
||||
|
||||
return (
|
||||
<div className='mt-8 w-full' style={{ contain: 'layout paint' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={grid.svgWidth}
|
||||
height={grid.svgHeight}
|
||||
className='w-full'
|
||||
style={{ display: 'block', width: '100%', height: 'auto' }}
|
||||
role='img'
|
||||
aria-label='Release activity heatmap'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -4,10 +4,36 @@ import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
|
||||
|
||||
type Props = { initialEntries: ChangelogEntry[] }
|
||||
interface ChangelogListProps {
|
||||
initialEntries: ChangelogEntry[]
|
||||
onEntriesChange?: (entries: ChangelogEntry[]) => void
|
||||
onActiveEntryChange?: (activeTag: string | null) => void
|
||||
onProgressChange?: (progress: number) => void
|
||||
variant?: 'cards' | 'flat' | 'timeline'
|
||||
/** When set, paginate client-side instead of fetching from API. */
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(60, 1fr)',
|
||||
gap: '6px',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeContent(body: string): string {
|
||||
return body.replace(/ /g, '')
|
||||
@@ -16,7 +42,7 @@ function sanitizeContent(body: string): string {
|
||||
function stripContributors(body: string): string {
|
||||
let output = body
|
||||
output = output.replace(
|
||||
/(^|\n)#{1,6}\s*Contributors\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
/(^|\n)#{1,6}\s*(New\s+)?Contributors\b[^\n]*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
@@ -42,11 +68,28 @@ function stripPrReferences(body: string): string {
|
||||
return body.replace(/\s*\(\s*\[#\d+\]\([^)]*\)\s*\)/g, '').replace(/\s*\(\s*#\d+\s*\)/g, '')
|
||||
}
|
||||
|
||||
function stripChangelogFooter(body: string): string {
|
||||
return body
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const t = line.trim()
|
||||
if (t.startsWith('**Full Changelog**')) return false
|
||||
if (/^\[Full Changelog\]/i.test(t)) return false
|
||||
if (/^View (all )?changes? on GitHub/i.test(t)) return false
|
||||
if (/^\[View (all )?changes? on GitHub\]/i.test(t)) return false
|
||||
if (t === '[') return false
|
||||
return true
|
||||
})
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function cleanMarkdown(body: string): string {
|
||||
const sanitized = sanitizeContent(body)
|
||||
const withoutContribs = stripContributors(sanitized)
|
||||
const withoutPrs = stripPrReferences(withoutContribs)
|
||||
return withoutPrs
|
||||
const withoutFooter = stripChangelogFooter(withoutPrs)
|
||||
return withoutFooter
|
||||
}
|
||||
|
||||
function extractMentions(body: string): string[] {
|
||||
@@ -54,11 +97,101 @@ function extractMentions(body: string): string[] {
|
||||
return Array.from(new Set(matches.map((m) => m.slice(1))))
|
||||
}
|
||||
|
||||
export default function ChangelogList({ initialEntries }: Props) {
|
||||
/** Dot grid pattern matching Figma card header background */
|
||||
const dotGridStyle: React.CSSProperties = {
|
||||
backgroundImage: 'radial-gradient(circle, #2e2e2e 1px, transparent 1px)',
|
||||
backgroundSize: '10px 10px',
|
||||
}
|
||||
|
||||
export default function ChangelogList({
|
||||
initialEntries,
|
||||
onEntriesChange,
|
||||
onActiveEntryChange,
|
||||
onProgressChange,
|
||||
variant = 'cards',
|
||||
pageSize,
|
||||
}: ChangelogListProps) {
|
||||
const [entries, setEntries] = React.useState<ChangelogEntry[]>(initialEntries)
|
||||
const [page, setPage] = React.useState<number>(1)
|
||||
const [loading, setLoading] = React.useState<boolean>(false)
|
||||
const [done, setDone] = React.useState<boolean>(false)
|
||||
const [done, setDone] = React.useState<boolean>(!!pageSize)
|
||||
const [visibleCount, setVisibleCount] = React.useState<number>(pageSize ?? initialEntries.length)
|
||||
const [activeTag, setActiveTag] = React.useState<string | null>(initialEntries[0]?.tag ?? null)
|
||||
const shouldTrackActiveEntry = Boolean(onActiveEntryChange)
|
||||
const cardRefs = React.useRef<Array<HTMLDivElement | null>>([])
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const displayedEntries = pageSize ? entries.slice(0, visibleCount) : entries
|
||||
const allRevealed = visibleCount >= entries.length
|
||||
|
||||
React.useEffect(() => {
|
||||
onEntriesChange?.(entries)
|
||||
}, [entries, onEntriesChange])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldTrackActiveEntry) return
|
||||
onActiveEntryChange?.(activeTag)
|
||||
}, [activeTag, onActiveEntryChange, shouldTrackActiveEntry])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldTrackActiveEntry) return
|
||||
if (!displayedEntries.length) {
|
||||
setActiveTag(null)
|
||||
return
|
||||
}
|
||||
|
||||
const halfVisible = new Set<number>()
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(observerEntries) => {
|
||||
for (const observerEntry of observerEntries) {
|
||||
const element = observerEntry.target as HTMLDivElement
|
||||
const index = Number(element.dataset.entryIndex)
|
||||
if (Number.isNaN(index)) continue
|
||||
|
||||
if (observerEntry.isIntersecting && observerEntry.intersectionRatio >= 0.5) {
|
||||
halfVisible.add(index)
|
||||
} else {
|
||||
halfVisible.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
if (halfVisible.size === 0) return
|
||||
|
||||
const targetIdx = Math.min(...halfVisible)
|
||||
const tag = displayedEntries[targetIdx]?.tag ?? null
|
||||
setActiveTag((prev) => (prev === tag ? prev : tag))
|
||||
|
||||
if (onProgressChange) {
|
||||
const total = Math.max(1, displayedEntries.length - 1)
|
||||
onProgressChange(targetIdx / total)
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
for (let index = 0; index < displayedEntries.length; index += 1) {
|
||||
const card = cardRefs.current[index]
|
||||
if (!card) continue
|
||||
observer.observe(card)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
halfVisible.clear()
|
||||
}
|
||||
}, [displayedEntries, shouldTrackActiveEntry, variant])
|
||||
|
||||
const handleShowMore = () => {
|
||||
if (pageSize) {
|
||||
setVisibleCount((c) => Math.min(c + pageSize, entries.length))
|
||||
return
|
||||
}
|
||||
loadMore()
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || done) return
|
||||
@@ -94,137 +227,316 @@ export default function ChangelogList({ initialEntries }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
h2: ({ children, ...props }: any) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h3
|
||||
className='mt-5 mb-2 font-[430] font-season text-[#F6F6F6] text-[13px] tracking-[-0.02em] [&:first-child]:mt-0'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h3: ({ children, ...props }: any) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h4
|
||||
className='mt-4 mb-1 font-[430] font-season text-[#F6F6F6] text-[13px] tracking-[-0.02em] [&:first-child]:mt-0'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children, ...props }: any) => {
|
||||
const text = String(children)
|
||||
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
|
||||
return (
|
||||
<li
|
||||
className='font-normal font-season text-[#F6F6F0]/50 text-[13px] leading-[125%] tracking-[0.02em]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
p: ({ children, ...props }: any) =>
|
||||
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
|
||||
<p
|
||||
className='mb-3 font-normal font-season text-[#F6F6F0]/50 text-[13px] leading-[125%] tracking-[0.02em]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong className='font-medium text-[#F6F6F6]' {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }: any) => (
|
||||
<code
|
||||
className='rounded bg-[#2A2A2A] px-1 py-0.5 font-mono text-[#F6F6F6] text-xs'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children, ...props }: any) => (
|
||||
<pre {...props} suppressHydrationWarning>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: () => null,
|
||||
a: ({ className, ...props }: any) => (
|
||||
<a {...props} className={`underline ${className ?? ''}`} target='_blank' rel='noreferrer' />
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-10'>
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.tag}>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
|
||||
{entry.tag}
|
||||
</div>
|
||||
{entry.contributors && entry.contributors.length > 0 && (
|
||||
<div className='-space-x-2 flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className='block'
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-background'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 && (
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={
|
||||
variant === 'flat'
|
||||
? 'flex flex-col'
|
||||
: variant === 'timeline'
|
||||
? 'relative flex flex-col'
|
||||
: 'flex flex-col gap-4'
|
||||
}
|
||||
>
|
||||
{displayedEntries.map((entry, index) => {
|
||||
const setRef = (element: HTMLDivElement | null) => {
|
||||
cardRefs.current[index] = element
|
||||
}
|
||||
|
||||
if (variant === 'timeline') {
|
||||
return (
|
||||
<React.Fragment key={entry.tag}>
|
||||
{index > 0 && (
|
||||
<div className='-translate-x-1/2 relative left-1/2 w-[100vw] md:w-[50vw]'>
|
||||
<DotSeparator />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${inter.className} text-muted-foreground text-xs`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setRef} data-entry-index={index} data-tag={entry.tag} className='py-8'>
|
||||
<div className='mb-5 flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<a
|
||||
href={entry.url}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
|
||||
>
|
||||
<span className='relative'>
|
||||
{entry.tag}
|
||||
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
|
||||
</span>
|
||||
</a>
|
||||
{entry.contributors && entry.contributors.length > 0 && (
|
||||
<div className='-space-x-2 flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className='block'
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
|
||||
{contributor.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 && (
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${inter.className} shrink-0 text-[#F6F6F6]/50 text-xs`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'flat') {
|
||||
return (
|
||||
<React.Fragment key={entry.tag}>
|
||||
{index > 0 && <DotSeparator />}
|
||||
<div ref={setRef} data-entry-index={index} data-tag={entry.tag} className='py-8'>
|
||||
<div className='mb-5 flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<a
|
||||
href={entry.url}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
|
||||
>
|
||||
<span className='relative'>
|
||||
{entry.tag}
|
||||
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
|
||||
</span>
|
||||
</a>
|
||||
{entry.contributors && entry.contributors.length > 0 && (
|
||||
<div className='-space-x-2 flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className='block'
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
|
||||
{contributor.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 && (
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${inter.className} shrink-0 text-[#F6F6F6]/50 text-xs`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${inter.className} prose prose-sm dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-brand-primary prose-headings:text-foreground prose-p:text-muted-foreground prose-a:no-underline hover:prose-a:underline`}
|
||||
key={entry.tag}
|
||||
ref={setRef}
|
||||
data-entry-index={index}
|
||||
data-tag={entry.tag}
|
||||
className='overflow-hidden rounded-[14px] border border-[#4d4d4d] bg-[#1b1b1b]'
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h2: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h3
|
||||
className={`${soehne.className} mt-5 mb-2 font-medium text-[13px] text-foreground tracking-tight`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h3: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h4
|
||||
className={`${soehne.className} mt-4 mb-1 font-medium text-[13px] text-foreground tracking-tight`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children, ...props }) => {
|
||||
const text = String(children)
|
||||
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
|
||||
return (
|
||||
<li className='text-[13px] text-muted-foreground leading-relaxed' {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
p: ({ children, ...props }) =>
|
||||
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
|
||||
<p
|
||||
className='mb-3 text-[13px] text-muted-foreground leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className='font-medium text-foreground' {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }) => (
|
||||
<code
|
||||
className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
img: () => null,
|
||||
a: ({ className, ...props }: any) => (
|
||||
<a
|
||||
{...props}
|
||||
className={`underline ${className ?? ''}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
{/* Card header with dot grid pattern */}
|
||||
<div
|
||||
className='relative flex items-center justify-between gap-4 border-[#2a2a2a] border-b px-5 py-4'
|
||||
style={dotGridStyle}
|
||||
>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className='flex items-center gap-3'>
|
||||
<a
|
||||
href={entry.url}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
|
||||
>
|
||||
<span className='relative'>
|
||||
{entry.tag}
|
||||
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
|
||||
</span>
|
||||
</a>
|
||||
{entry.contributors && entry.contributors.length > 0 && (
|
||||
<div className='-space-x-2 flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className='block'
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
|
||||
{contributor.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 && (
|
||||
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${inter.className} text-[#F6F6F6]/50 text-xs`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
{/* Card body */}
|
||||
<div className='px-5 py-4'>
|
||||
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!(pageSize ? allRevealed : done) && (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={loadMore}
|
||||
onClick={handleShowMore}
|
||||
disabled={loading}
|
||||
className='rounded-md border border-border px-3 py-1.5 text-[13px] hover:bg-muted disabled:opacity-60'
|
||||
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1.5 text-[#F6F6F6] text-[13px] hover:bg-[rgba(246,246,240,0.1)] disabled:opacity-60'
|
||||
>
|
||||
{loading ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
|
||||
121
apps/sim/app/changelog/components/variants/breaks.tsx
Normal file
121
apps/sim/app/changelog/components/variants/breaks.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
|
||||
import { CommitHeatmap, type CommitHeatmapHandle } from '@/app/changelog/components/commit-heatmap'
|
||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||
|
||||
interface BreaksProps {
|
||||
entries: ChangelogEntry[]
|
||||
}
|
||||
|
||||
export function Breaks({ entries }: BreaksProps) {
|
||||
const [loadedEntries, setLoadedEntries] = useState<ChangelogEntry[]>(entries)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(entries[0]?.tag ?? null)
|
||||
const heatmapRef = useRef<CommitHeatmapHandle>(null)
|
||||
const entriesRef = useRef(loadedEntries)
|
||||
entriesRef.current = loadedEntries
|
||||
|
||||
const handleActiveEntryChange = useCallback((tag: string | null) => {
|
||||
setActiveTag(tag)
|
||||
if (tag) {
|
||||
const entry = entriesRef.current.find((e) => e.tag === tag)
|
||||
if (entry) {
|
||||
heatmapRef.current?.setActiveDate(entry.date.split('T')[0])
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEntriesChange = useCallback((nextEntries: ChangelogEntry[]) => {
|
||||
setLoadedEntries(nextEntries)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C]'>
|
||||
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
|
||||
{/* Left intro panel */}
|
||||
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
{/* Background card decoration */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
{/* Union decorative shape */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
|
||||
>
|
||||
<Image
|
||||
src='/landing/union-right.svg'
|
||||
alt=''
|
||||
width={768}
|
||||
height={768}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
|
||||
changes are documented here with detailed release notes.
|
||||
</p>
|
||||
|
||||
<div className='mt-5 flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
<Github className='h-4 w-4' />
|
||||
View on GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<BookOpen className='h-4 w-4' />
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog.xml'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<Rss className='h-4 w-4' />
|
||||
RSS Feed
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CommitHeatmap
|
||||
ref={heatmapRef}
|
||||
entries={loadedEntries}
|
||||
activeDate={entries[0]?.date.split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right timeline */}
|
||||
<div className='relative overflow-x-clip px-4 py-10 sm:px-6 md:px-8 md:py-12'>
|
||||
<div className='relative mx-auto max-w-2xl'>
|
||||
<ChangelogList
|
||||
initialEntries={entries}
|
||||
variant='timeline'
|
||||
pageSize={10}
|
||||
onEntriesChange={handleEntriesChange}
|
||||
onActiveEntryChange={handleActiveEntryChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
apps/sim/app/changelog/components/variants/scroll.tsx
Normal file
88
apps/sim/app/changelog/components/variants/scroll.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ChangelogBlocks } from '@/app/changelog/components/changelog-blocks'
|
||||
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
|
||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||
|
||||
interface ScrollProps {
|
||||
entries: ChangelogEntry[]
|
||||
}
|
||||
|
||||
export function Scroll({ entries }: ScrollProps) {
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C]'>
|
||||
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
|
||||
{/* Left intro panel */}
|
||||
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
|
||||
{/* Background card decoration */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
{/* Union decorative shape */}
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
|
||||
>
|
||||
<Image
|
||||
src='/landing/union-right.svg'
|
||||
alt=''
|
||||
width={768}
|
||||
height={768}
|
||||
className='h-auto w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChangelogBlocks mode='scroll' />
|
||||
|
||||
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
|
||||
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
|
||||
changes are documented here with detailed release notes.
|
||||
</p>
|
||||
|
||||
<div className='mt-5 flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
>
|
||||
<Github className='h-4 w-4' />
|
||||
View on GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href='https://docs.sim.ai'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<BookOpen className='h-4 w-4' />
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href='/changelog.xml'
|
||||
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
|
||||
>
|
||||
<Rss className='h-4 w-4' />
|
||||
RSS Feed
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right timeline */}
|
||||
<div className='relative px-4 py-10 sm:px-6 md:px-8 md:py-12'>
|
||||
<div className='relative max-w-2xl'>
|
||||
<ChangelogList initialEntries={entries} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default function ChangelogLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='relative min-h-screen text-foreground'>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<Nav />
|
||||
<div className={`${season.variable} ${martianMono.variable} relative min-h-screen`}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-[#1C1C1C]' />
|
||||
<Navbar />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { ChangelogVariant } from '@/app/changelog/components/changelog-content'
|
||||
import ChangelogContent from '@/app/changelog/components/changelog-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -11,6 +12,16 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return <ChangelogContent />
|
||||
const VALID_VARIANTS: ChangelogVariant[] = ['breaks', 'scroll']
|
||||
|
||||
interface ChangelogPageProps {
|
||||
searchParams: Promise<{ v?: string }>
|
||||
}
|
||||
|
||||
export default async function ChangelogPage({ searchParams }: ChangelogPageProps) {
|
||||
const { v } = await searchParams
|
||||
const variant = VALID_VARIANTS.includes(v as ChangelogVariant)
|
||||
? (v as ChangelogVariant)
|
||||
: undefined
|
||||
return <ChangelogContent variant={variant} />
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
var width = state && state.sidebarWidth;
|
||||
var maxSidebarWidth = window.innerWidth * 0.3;
|
||||
|
||||
if (width >= 232 && width <= maxSidebarWidth) {
|
||||
if (width >= 248 && width <= maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
||||
} else if (width > maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
|
||||
|
||||
@@ -3,18 +3,18 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
export async function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const llmsFullContent = `# Sim - AI Agent Workflow Builder
|
||||
const llmsFullContent = `# Sim — Build AI Agents & Run Your Agentic Workforce
|
||||
|
||||
> Sim is an open-source AI agent workflow builder used by 60,000+ developers at startups to Fortune 500 companies. Build and deploy agentic workflows with a visual drag-and-drop canvas. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
Sim provides a visual interface for building AI agent workflows. Instead of writing code, users drag and drop blocks onto a canvas and connect them to create complex AI automations. Each block represents a step in the workflow - an LLM call, a tool invocation, an API request, or a code execution.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 and HIPAA compliant.
|
||||
|
||||
## Product Details
|
||||
|
||||
- **Product Name**: Sim
|
||||
- **Category**: AI Development Tools / Workflow Automation
|
||||
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
|
||||
- **Deployment**: Cloud (SaaS) and Self-hosted options
|
||||
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
|
||||
- **Compliance**: SOC2 Type II, HIPAA compliant
|
||||
@@ -66,7 +66,7 @@ Sim supports all major LLM providers:
|
||||
- Amazon Bedrock
|
||||
|
||||
### Integrations
|
||||
100+ pre-built integrations including:
|
||||
1,000+ pre-built integrations including:
|
||||
- **Communication**: Slack, Discord, Email (Gmail, Outlook), SMS (Twilio)
|
||||
- **Productivity**: Notion, Airtable, Google Sheets, Google Docs
|
||||
- **Development**: GitHub, GitLab, Jira, Linear
|
||||
@@ -81,6 +81,12 @@ Built-in support for:
|
||||
- Semantic search and retrieval
|
||||
- Chunking strategies (fixed size, semantic, recursive)
|
||||
|
||||
### Tables
|
||||
Built-in table creation and management:
|
||||
- Structured data storage
|
||||
- Queryable tables for agent workflows
|
||||
- Native integrations
|
||||
|
||||
### Code Execution
|
||||
- Sandboxed JavaScript/TypeScript execution
|
||||
- Access to npm packages
|
||||
|
||||
@@ -5,16 +5,16 @@ export async function GET() {
|
||||
|
||||
const llmsContent = `# Sim
|
||||
|
||||
> Sim is an open-source AI agent workflow builder. 60,000+ developers at startups to Fortune 500 companies deploy agentic workflows on the Sim platform. SOC2 and HIPAA compliant.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim provides a visual drag-and-drop interface for building and deploying AI agent workflows. Connect to 100+ integrations and ship production-ready AI automations.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.
|
||||
|
||||
## Core Pages
|
||||
|
||||
- [Homepage](${baseUrl}): Main landing page with product overview and features
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides for AI workflows
|
||||
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -29,28 +29,31 @@ Sim provides a visual drag-and-drop interface for building and deploying AI agen
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Visual workflow builder with drag-and-drop canvas
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI)
|
||||
- Retrieval-augmented generation (RAG) with vector databases
|
||||
- 100+ integrations (Slack, Gmail, Notion, Airtable, databases)
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
- Real-time collaboration and version control
|
||||
|
||||
## Use Cases
|
||||
|
||||
- AI agent workflow automation
|
||||
- RAG pipelines and document processing
|
||||
- Chatbot and copilot workflows for SaaS
|
||||
- Email and customer support automation
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with users
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
@@ -5,10 +5,10 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return {
|
||||
name: brand.name === 'Sim' ? 'Sim - AI Agent Workflow Builder' : brand.name,
|
||||
name: brand.name === 'Sim' ? 'Sim — Build AI Agents & Run Your Agentic Workforce' : brand.name,
|
||||
short_name: brand.name,
|
||||
description:
|
||||
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows on Sim. Visual drag-and-drop interface for creating AI automations. SOC2 and HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Landing from '@/app/(landing)/landing'
|
||||
import Landing from '@/app/(home)/landing'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source Platform',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source AI agent workflow builder used by 60,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
keywords:
|
||||
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, HIPAA compliant, enterprise AI',
|
||||
authors: [{ name: 'Sim' }],
|
||||
creator: 'Sim',
|
||||
publisher: 'Sim',
|
||||
@@ -20,9 +22,9 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform used by 60,000+ developers. Design, deploy, and monitor agentic workflows with a visual drag-and-drop interface, 100+ integrations, and enterprise-grade security.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Join over 100,000 builders.',
|
||||
type: 'website',
|
||||
url: baseUrl,
|
||||
siteName: 'Sim',
|
||||
@@ -32,7 +34,7 @@ export const metadata: Metadata = {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
width: 2130,
|
||||
height: 1200,
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
@@ -41,12 +43,12 @@ export const metadata: Metadata = {
|
||||
card: 'summary_large_image',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
|
||||
images: {
|
||||
url: '/logo/426-240/primary/small.png',
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
alt: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
@@ -72,11 +74,12 @@ export const metadata: Metadata = {
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:content-type':
|
||||
'AI agent platform, agentic workforce, agentic workflows, LLM orchestration',
|
||||
'llm:use-cases':
|
||||
'email automation, Slack bots, Discord moderation, data analysis, customer support, content generation, agentic automations',
|
||||
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
|
||||
'llm:integrations':
|
||||
'OpenAI, Anthropic, Google AI, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
|
||||
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
|
||||
'llm:region': 'global',
|
||||
'llm:languages': 'en',
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { NextError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
export default NextError
|
||||
const logger = createLogger('WorkspaceError')
|
||||
|
||||
interface WorkspaceErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceError({ error, reset }: WorkspaceErrorProps) {
|
||||
useEffect(() => {
|
||||
logger.error('Workspace error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-4)]'>
|
||||
<AlertTriangle className='h-[24px] w-[24px] text-[var(--text-error)]' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
An unexpected error occurred. Please try again or refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
25
apps/sim/app/workspace/[workspaceId]/files/files-view.tsx
Normal file
25
apps/sim/app/workspace/[workspaceId]/files/files-view.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { Files as FilesIcon } from 'lucide-react'
|
||||
import { Files } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/files/files'
|
||||
|
||||
export function FilesView() {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex items-center gap-3 border-[var(--border)] border-b px-6 py-4'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-md bg-[var(--surface-3)]'>
|
||||
<FilesIcon className='h-4 w-4 text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className='font-medium text-[var(--text-primary)] text-base'>Files</h1>
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
Workspace files accessible across all workflows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 overflow-hidden px-6 py-4'>
|
||||
<Files />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/sim/app/workspace/[workspaceId]/files/layout.tsx
Normal file
3
apps/sim/app/workspace/[workspaceId]/files/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function FilesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
32
apps/sim/app/workspace/[workspaceId]/files/page.tsx
Normal file
32
apps/sim/app/workspace/[workspaceId]/files/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { FilesView } from './files-view'
|
||||
|
||||
interface FilesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function FilesPage({ params }: FilesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideFilesTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
return <FilesView />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { NextGlobalError } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
|
||||
export default NextGlobalError
|
||||
458
apps/sim/app/workspace/[workspaceId]/home/home.tsx
Normal file
458
apps/sim/app/workspace/[workspaceId]/home/home.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp, Check, CircleAlert, Loader2, Zap } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
type ToolCallStatus = 'executing' | 'success' | 'error'
|
||||
|
||||
interface ToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: ToolCallStatus
|
||||
displayTitle?: string
|
||||
}
|
||||
|
||||
type ContentBlockType = 'text' | 'tool_call' | 'subagent'
|
||||
|
||||
interface ContentBlock {
|
||||
type: ContentBlockType
|
||||
content?: string
|
||||
toolCall?: ToolCallInfo
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
contentBlocks?: ContentBlock[]
|
||||
}
|
||||
|
||||
const SUBAGENT_LABELS: Record<string, string> = {
|
||||
build: 'Building',
|
||||
deploy: 'Deploying',
|
||||
auth: 'Connecting credentials',
|
||||
research: 'Researching',
|
||||
knowledge: 'Managing knowledge base',
|
||||
table: 'Managing tables',
|
||||
custom_tool: 'Creating tool',
|
||||
superagent: 'Executing action',
|
||||
plan: 'Planning',
|
||||
debug: 'Debugging',
|
||||
edit: 'Editing workflow',
|
||||
}
|
||||
|
||||
const SEND_BUTTON_BASE = 'h-7 w-7 rounded-full border-0 p-0 transition-colors'
|
||||
const SEND_BUTTON_ACTIVE =
|
||||
'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
|
||||
const TEXTAREA_CLASSES =
|
||||
'm-0 box-border h-auto max-h-[30vh] min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-1 py-1 font-medium font-sans text-sm text-[var(--text-primary)] leading-5 outline-none placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
|
||||
function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
|
||||
switch (status) {
|
||||
case 'executing':
|
||||
return <Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
|
||||
case 'success':
|
||||
return <Check className='h-3 w-3 text-emerald-500' />
|
||||
case 'error':
|
||||
return <CircleAlert className='h-3 w-3 text-red-400' />
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
|
||||
const label =
|
||||
toolCall.displayTitle ||
|
||||
toolCall.name
|
||||
.replace(/_v\d+$/, '')
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5'>
|
||||
<Zap className='h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-secondary)] text-xs'>{label}</span>
|
||||
<ToolStatusIcon status={toolCall.status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantBlocks({
|
||||
blocks,
|
||||
isStreaming,
|
||||
}: {
|
||||
blocks: ContentBlock[]
|
||||
isStreaming: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
if (!block.content?.trim()) return null
|
||||
return (
|
||||
<div key={`text-${i}`} className='prose-sm prose-invert max-w-none'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{block.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'tool_call': {
|
||||
if (!block.toolCall) return null
|
||||
return <ToolCallItem key={block.toolCall.id} toolCall={block.toolCall} />
|
||||
}
|
||||
case 'subagent': {
|
||||
if (!block.content) return null
|
||||
const isLast = isStreaming && blocks.slice(i + 1).every((b) => b.type !== 'subagent')
|
||||
if (!isLast) return null
|
||||
return (
|
||||
<div key={`sub-${i}`} className='flex items-center gap-2 py-0.5'>
|
||||
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
|
||||
<span className='text-[var(--text-tertiary)] text-xs'>{block.content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(undefined)
|
||||
const chatBottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
if (!message.trim() || !workspaceId) return
|
||||
|
||||
setError(null)
|
||||
setIsSending(true)
|
||||
|
||||
const userMessageId = crypto.randomUUID()
|
||||
const assistantId = crypto.randomUUID()
|
||||
|
||||
console.log('[SSE] user:', JSON.stringify({ type: 'user', message }))
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: userMessageId, role: 'user', content: message },
|
||||
{ id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
|
||||
])
|
||||
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
const blocks: ContentBlock[] = []
|
||||
const toolMap = new Map<string, number>()
|
||||
|
||||
const ensureTextBlock = (): ContentBlock => {
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.type === 'text') return last
|
||||
const b: ContentBlock = { type: 'text', content: '' }
|
||||
blocks.push(b)
|
||||
return b
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
const text = blocks
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => b.content ?? '')
|
||||
.join('')
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: text, contentBlocks: [...blocks] } : m
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(MOTHERSHIP_CHAT_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
workspaceId,
|
||||
userMessageId,
|
||||
createNewChat: !chatIdRef.current,
|
||||
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || `Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.body) throw new Error('No response body')
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6)
|
||||
|
||||
console.log('[SSE] event:', payload)
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'chat_id': {
|
||||
if (parsed.chatId) chatIdRef.current = parsed.chatId
|
||||
break
|
||||
}
|
||||
case 'content': {
|
||||
const chunk = typeof parsed.data === 'string' ? parsed.data : parsed.content || ''
|
||||
if (chunk) {
|
||||
const tb = ensureTextBlock()
|
||||
tb.content = (tb.content ?? '') + chunk
|
||||
flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_generating':
|
||||
case 'tool_call': {
|
||||
const id = parsed.toolCallId
|
||||
const name = parsed.toolName || parsed.data?.name || 'unknown'
|
||||
if (!id) break
|
||||
const ui = parsed.ui || parsed.data?.ui
|
||||
if (ui?.hidden) break
|
||||
const displayTitle = ui?.title || ui?.phaseLabel
|
||||
if (!toolMap.has(id)) {
|
||||
toolMap.set(id, blocks.length)
|
||||
blocks.push({
|
||||
type: 'tool_call',
|
||||
toolCall: { id, name, status: 'executing', displayTitle },
|
||||
})
|
||||
} else {
|
||||
const idx = toolMap.get(id)!
|
||||
const tc = blocks[idx].toolCall
|
||||
if (tc) {
|
||||
tc.name = name
|
||||
if (displayTitle) tc.displayTitle = displayTitle
|
||||
}
|
||||
}
|
||||
flush()
|
||||
break
|
||||
}
|
||||
case 'tool_result': {
|
||||
const id = parsed.toolCallId || parsed.data?.id
|
||||
if (!id) break
|
||||
const idx = toolMap.get(id)
|
||||
if (idx !== undefined && blocks[idx].toolCall) {
|
||||
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
|
||||
flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_error': {
|
||||
const id = parsed.toolCallId || parsed.data?.id
|
||||
if (!id) break
|
||||
const idx = toolMap.get(id)
|
||||
if (idx !== undefined && blocks[idx].toolCall) {
|
||||
blocks[idx].toolCall!.status = 'error'
|
||||
flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'subagent_start': {
|
||||
const name = parsed.subagent || parsed.data?.agent
|
||||
if (name) {
|
||||
blocks.push({ type: 'subagent', content: SUBAGENT_LABELS[name] || name })
|
||||
flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'subagent_end': {
|
||||
flush()
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
setError(parsed.error || 'An error occurred')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
const msg = err instanceof Error ? err.message : 'Failed to send message'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (!trimmed) return
|
||||
setInputValue('')
|
||||
sendMessage(trimmed)
|
||||
}, [inputValue, sendMessage])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const canSubmit = inputValue.trim().length > 0 && !isSending
|
||||
|
||||
const inputBar = (
|
||||
<div className='mx-auto w-full max-w-[640px] rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-2 shadow-sm dark:bg-[var(--surface-4)]'>
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder='What do you want to build?'
|
||||
rows={2}
|
||||
className={TEXTAREA_CLASSES}
|
||||
/>
|
||||
<div className='flex items-center justify-end'>
|
||||
{isSending ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
abortControllerRef.current?.abort()
|
||||
setIsSending(false)
|
||||
}}
|
||||
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
|
||||
title='Stop generation'
|
||||
>
|
||||
<svg
|
||||
className='block h-3.5 w-3.5 fill-white dark:fill-black'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||
</svg>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
|
||||
>
|
||||
<ArrowUp className='block h-4 w-4 text-white dark:text-black' strokeWidth={2.25} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center px-6'>
|
||||
<h1 className='mb-6 font-semibold text-2xl text-[var(--text-primary)]'>
|
||||
What do you want to build?
|
||||
</h1>
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-4 py-4'>
|
||||
<div className='mx-auto max-w-3xl space-y-4'>
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-end'>
|
||||
<div className='max-w-[85%] rounded-lg bg-[var(--accent)] px-4 py-2 text-[var(--accent-foreground)] text-sm'>
|
||||
<p className='whitespace-pre-wrap'>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
|
||||
const isThisStreaming = isSending && msg === messages[messages.length - 1]
|
||||
|
||||
if (!hasBlocks && !msg.content && isThisStreaming) {
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-start'>
|
||||
<div className='flex items-center gap-2 rounded-lg bg-[var(--surface-3)] px-4 py-2 text-[var(--text-secondary)] text-sm'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Thinking...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasBlocks && !msg.content) return null
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-start'>
|
||||
<div className='max-w-[85%] rounded-lg bg-[var(--surface-3)] px-4 py-2 text-[var(--text-primary)] text-sm'>
|
||||
{hasBlocks ? (
|
||||
<AssistantBlocks blocks={msg.contentBlocks!} isStreaming={isThisStreaming} />
|
||||
) : (
|
||||
<div className='prose-sm prose-invert max-w-none'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={chatBottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='px-6 pb-2'>
|
||||
<p className='text-red-500 text-xs'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex-shrink-0 border-[var(--border)] border-t px-6 py-4'>{inputBar}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/sim/app/workspace/[workspaceId]/home/layout.tsx
Normal file
3
apps/sim/app/workspace/[workspaceId]/home/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
26
apps/sim/app/workspace/[workspaceId]/home/page.tsx
Normal file
26
apps/sim/app/workspace/[workspaceId]/home/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { Home } from './home'
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <Home />
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Knowledge Base layout - applies sidebar padding for all knowledge routes.
|
||||
*/
|
||||
export default function KnowledgeLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full bg-[var(--bg)]'>
|
||||
<div className='flex h-screen w-full bg-[#F4F4F4] dark:bg-[var(--surface-1)]'>
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{children}
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
|
||||
@@ -88,9 +88,11 @@ function WorkflowsListInner({
|
||||
{/* Workflow name with color */}
|
||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
|
||||
@@ -408,14 +408,21 @@ export const LogDetails = memo(function LogDetails({
|
||||
Workflow
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{
|
||||
backgroundColor:
|
||||
log.workflow?.color ||
|
||||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined),
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const c =
|
||||
log.workflow?.color ||
|
||||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined)
|
||||
return (
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: c ? `${c}60` : undefined,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{log.workflow?.name ||
|
||||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
|
||||
|
||||
@@ -95,8 +95,12 @@ const LogRow = memo(
|
||||
className={`flex ${LOG_COLUMNS.workflow.width} ${LOG_COLUMNS.workflow.minWidth} items-center gap-[8px] pr-[8px]`}
|
||||
>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: workflowColor,
|
||||
borderColor: `${workflowColor}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -104,20 +104,33 @@ const colorIconCache = new Map<string, React.ComponentType<{ className?: string
|
||||
* Uses a cache to ensure the same color always returns the same component reference,
|
||||
* which prevents unnecessary React reconciliation.
|
||||
* @param color - CSS color value for the icon background
|
||||
* @param withRing - Whether to render the semi-transparent outer ring
|
||||
* @returns A React component that renders a colored square icon
|
||||
*/
|
||||
function getColorIcon(color: string): React.ComponentType<{ className?: string }> {
|
||||
const cached = colorIconCache.get(color)
|
||||
function getColorIcon(
|
||||
color: string,
|
||||
withRing = false
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
const cacheKey = withRing ? `${color}-ring` : color
|
||||
const cached = colorIconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const ColorIcon = ({ className }: { className?: string }) => (
|
||||
<div
|
||||
className={cn(className, 'flex-shrink-0 rounded-[3px]')}
|
||||
style={{ backgroundColor: color, width: 10, height: 10 }}
|
||||
className={cn(className, 'flex-shrink-0 rounded-[3px]', withRing && 'border-[1.5px]')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 10,
|
||||
height: 10,
|
||||
...(withRing && {
|
||||
borderColor: `${color}60`,
|
||||
backgroundClip: 'padding-box' as const,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
ColorIcon.displayName = `ColorIcon(${color})`
|
||||
colorIconCache.set(color, ColorIcon)
|
||||
ColorIcon.displayName = `ColorIcon(${color}${withRing ? '-ring' : ''})`
|
||||
colorIconCache.set(cacheKey, ColorIcon)
|
||||
return ColorIcon
|
||||
}
|
||||
|
||||
@@ -248,7 +261,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
}, [selectedStatuses])
|
||||
|
||||
const workflowOptions: ComboboxOption[] = useMemo(
|
||||
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color) })),
|
||||
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: getColorIcon(w.color, true) })),
|
||||
[workflows]
|
||||
)
|
||||
|
||||
@@ -526,8 +539,12 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||
{selectedWorkflow && (
|
||||
<div
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
|
||||
style={{ backgroundColor: selectedWorkflow.color }}
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: selectedWorkflow.color,
|
||||
borderColor: `${selectedWorkflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||
@@ -653,8 +670,12 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
<span className='flex items-center gap-[6px] truncate text-[var(--text-primary)]'>
|
||||
{selectedWorkflow && (
|
||||
<div
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px]'
|
||||
style={{ backgroundColor: selectedWorkflow.color }}
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px] border-[1.5px]'
|
||||
style={{
|
||||
backgroundColor: selectedWorkflow.color,
|
||||
borderColor: `${selectedWorkflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default function LogsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default function TablesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Templates layout - applies sidebar padding for all template routes.
|
||||
*/
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
return <main className='flex h-full flex-1 flex-col overflow-hidden'>{children}</main>
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export const ActionBar = memo(
|
||||
<Tooltip.Content side='top'>
|
||||
{(() => {
|
||||
if (disabled) return getTooltipMessage('Run from block')
|
||||
if (isExecuting) return 'Execution in progress'
|
||||
if (isExecuting) return 'Running...'
|
||||
if (!dependenciesSatisfied) return 'Run previous blocks first'
|
||||
return 'Run from block'
|
||||
})()}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -15,8 +13,6 @@ const NOTIFICATION_WIDTH = 240
|
||||
const NOTIFICATION_GAP = 16
|
||||
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
@@ -144,17 +140,15 @@ export const DiffControls = memo(function DiffControls() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isPanelResizing
|
||||
|
||||
const notificationOffset = hasVisibleNotifications ? NOTIFICATION_WIDTH + NOTIFICATION_GAP : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx('fixed z-30', !isResizing && 'transition-[bottom] duration-100 ease-out')}
|
||||
className='absolute z-30'
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
right: `calc(var(--panel-width) + 16px + ${notificationOffset}px)`,
|
||||
bottom: '16px',
|
||||
right: `${16 + notificationOffset}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
openCopilotWithMessage,
|
||||
useNotificationStore,
|
||||
} from '@/stores/notifications'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Notifications')
|
||||
@@ -36,8 +33,6 @@ export const Notifications = memo(function Notifications() {
|
||||
.filter((n) => !n.workflowId || n.workflowId === activeWorkflowId)
|
||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
|
||||
/**
|
||||
* Executes a notification action and handles side effects.
|
||||
@@ -108,19 +103,10 @@ export const Notifications = memo(function Notifications() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isPanelResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'fixed z-30 flex flex-col items-start',
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
)}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
right: 'calc(var(--panel-width) + 16px)',
|
||||
}}
|
||||
className='absolute right-[16px] bottom-[16px] z-30 flex flex-col items-start'
|
||||
>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
const depth = stacked.length - index - 1
|
||||
|
||||
@@ -6,10 +6,7 @@ import clsx from 'clsx'
|
||||
import { ChevronUp, LayoutList } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
|
||||
import {
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS,
|
||||
executeRunToolOnClient,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import { executeRunToolOnClient } from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import {
|
||||
ClientToolCallState,
|
||||
TOOL_DISPLAY_REGISTRY,
|
||||
@@ -1231,36 +1228,17 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
||||
)
|
||||
})
|
||||
|
||||
/** Checks if a tool is server-side executed (not a client tool) */
|
||||
function isIntegrationTool(toolName: string): boolean {
|
||||
return !TOOL_DISPLAY_REGISTRY[toolName]
|
||||
}
|
||||
|
||||
/**
|
||||
* Show approval buttons when the tool is in pending state.
|
||||
* The Go backend already decided whether confirmation is needed via
|
||||
* `requiresConfirmation` — if set, the SSE handler puts the tool in
|
||||
* `pending`; otherwise it goes straight to `executing`.
|
||||
*/
|
||||
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||
if (!toolCall.name || toolCall.name === 'unknown_tool') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (toolCall.state !== ClientToolCallState.pending) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Never show buttons for tools the user has marked as always-allowed
|
||||
if (useCopilotStore.getState().isToolAutoAllowed(toolCall.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasInterrupt = !!TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt
|
||||
if (hasInterrupt) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Integration tools (user-installed) always require approval
|
||||
if (isIntegrationTool(toolCall.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return toolCall.state === ClientToolCallState.pending
|
||||
}
|
||||
|
||||
const toolCallLogger = createLogger('CopilotToolCall')
|
||||
@@ -1294,10 +1272,7 @@ async function handleRun(
|
||||
onStateChange?.('executing')
|
||||
await sendToolDecision(toolCall.id, 'accepted')
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback
|
||||
// (block pulsing, console logs, stop button). The server defers execution
|
||||
// for these tools; the client reports back via mark-complete.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)) {
|
||||
if (toolCall.clientExecutable) {
|
||||
const params = editedParams || toolCall.params || {}
|
||||
executeRunToolOnClient(toolCall.id, toolCall.name, params)
|
||||
}
|
||||
@@ -1461,9 +1436,7 @@ export function ToolCall({
|
||||
|
||||
// Check if this integration tool is auto-allowed
|
||||
const { removeAutoAllowedTool, setToolCallState } = useCopilotStore()
|
||||
const isAutoAllowed = useCopilotStore(
|
||||
(s) => isIntegrationTool(toolCall.name) && s.isToolAutoAllowed(toolCall.name)
|
||||
)
|
||||
const isAutoAllowed = useCopilotStore((s) => s.isToolAutoAllowed(toolCall.name))
|
||||
|
||||
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
|
||||
useEffect(() => {
|
||||
@@ -1528,9 +1501,9 @@ export function ToolCall({
|
||||
// 2. We're in build mode (integration tools are executed server-side), OR
|
||||
// 3. Tool call is already completed (historical - should always render)
|
||||
const isClientTool = !!TOOL_DISPLAY_REGISTRY[toolCall.name]
|
||||
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
|
||||
const isServerToolInBuildMode = mode === 'build' && !isClientTool
|
||||
|
||||
if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) {
|
||||
if (!isClientTool && !isServerToolInBuildMode && !isCompletedToolCall) {
|
||||
return null
|
||||
}
|
||||
const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig
|
||||
|
||||
@@ -27,7 +27,17 @@ export function BlockIcon({
|
||||
}
|
||||
|
||||
export function WorkflowColorDot({ color }: { color?: string }) {
|
||||
return <div className={ICON_CONTAINER} style={{ backgroundColor: color || '#3972F6' }} />
|
||||
const c = color || '#3972F6'
|
||||
return (
|
||||
<div
|
||||
className={`${ICON_CONTAINER} border-[2px]`}
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: `${c}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface FolderContentProps {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useCallback, useEffect } from 'react'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Custom hook to handle panel resize functionality.
|
||||
* Manages mouse events for resizing and enforces min/max width constraints.
|
||||
@@ -27,8 +30,7 @@ export function usePanelResize() {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Calculate width from the right edge of the viewport
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - e.clientX
|
||||
const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE
|
||||
|
||||
if (newWidth >= PANEL_WIDTH.MIN && newWidth <= maxWidth) {
|
||||
|
||||
@@ -396,7 +396,7 @@ export const Panel = memo(function Panel() {
|
||||
<>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className='panel-container fixed inset-y-0 right-0 z-10 overflow-hidden bg-[var(--surface-1)]'
|
||||
className='panel-container relative shrink-0 overflow-hidden bg-[var(--surface-1)]'
|
||||
aria-label='Workflow panel'
|
||||
>
|
||||
<div className='flex h-full flex-col border-[var(--border)] border-l pt-[14px]'>
|
||||
@@ -585,16 +585,16 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='fixed top-0 right-[calc(var(--panel-width)-4px)] bottom-0 z-20 w-[8px] cursor-ew-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize panel'
|
||||
/>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='absolute top-0 bottom-0 left-[-4px] z-20 w-[8px] cursor-ew-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize panel'
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
|
||||
@@ -14,16 +14,12 @@ export function useOutputPanelResize() {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const terminalEl = document.querySelector('[aria-label="Terminal"]')
|
||||
if (!terminalEl) return
|
||||
|
||||
const newWidth = window.innerWidth - e.clientX - panelWidth
|
||||
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
|
||||
const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH
|
||||
const terminalRect = terminalEl.getBoundingClientRect()
|
||||
const newWidth = terminalRect.right - e.clientX
|
||||
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
|
||||
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
|
||||
|
||||
setOutputPanelWidth(clampedWidth)
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useTerminalStore } from '@/stores/terminal'
|
||||
const MIN_HEIGHT = 30
|
||||
const MAX_HEIGHT_PERCENTAGE = 0.7
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
export function useTerminalResize() {
|
||||
const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight)
|
||||
const isResizing = useTerminalStore((state) => state.isResizing)
|
||||
@@ -17,7 +20,7 @@ export function useTerminalResize() {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newHeight = window.innerHeight - e.clientY
|
||||
const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - e.clientY
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_PERCENTAGE
|
||||
|
||||
if (newHeight >= MIN_HEIGHT && newHeight <= maxHeight) {
|
||||
|
||||
@@ -1271,19 +1271,10 @@ export const Terminal = memo(function Terminal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='fixed right-[var(--panel-width)] bottom-[calc(var(--terminal-height)-4px)] left-[var(--sidebar-width)] z-20 h-[8px] cursor-ns-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-label='Resize terminal'
|
||||
aria-orientation='horizontal'
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref={terminalRef}
|
||||
className={clsx(
|
||||
'terminal-container fixed right-[var(--panel-width)] bottom-0 left-[var(--sidebar-width)] z-10 overflow-hidden border-[var(--border)] border-t bg-[var(--surface-1)]',
|
||||
'terminal-container relative shrink-0 overflow-hidden border-[var(--border)] border-t bg-[var(--surface-1)]',
|
||||
isToggling && 'transition-[height] duration-100 ease-out'
|
||||
)}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
@@ -1292,6 +1283,15 @@ export const Terminal = memo(function Terminal() {
|
||||
tabIndex={-1}
|
||||
aria-label='Terminal'
|
||||
>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='absolute top-[-4px] right-0 left-0 z-20 h-[8px] cursor-ns-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-orientation='horizontal'
|
||||
aria-label='Resize terminal'
|
||||
/>
|
||||
|
||||
<div className='relative flex h-full'>
|
||||
{/* Left Section - Logs */}
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { Scan } from 'lucide-react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import {
|
||||
@@ -26,7 +25,6 @@ import { useShowActionBar, useUpdateGeneralSetting } from '@/hooks/queries/gener
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -42,8 +40,6 @@ export const WorkflowControls = memo(function WorkflowControls() {
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const showWorkflowControls = useShowActionBar()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
@@ -90,14 +86,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
)}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
left: 'calc(var(--sidebar-width) + 16px)',
|
||||
}}
|
||||
className='absolute bottom-[16px] left-[16px] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]'
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Canvas Mode Selector */}
|
||||
|
||||
@@ -8,6 +8,9 @@ interface UseFloatBoundarySyncProps {
|
||||
onPositionChange: (position: { x: number; y: number }) => void
|
||||
}
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Hook to synchronize floats position with layout boundary changes.
|
||||
* Keeps the float within bounds when sidebar, panel, or terminal resize.
|
||||
@@ -49,9 +52,9 @@ export function useFloatBoundarySync({
|
||||
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - panelWidth - width
|
||||
const minY = 0
|
||||
const maxY = window.innerHeight - terminalHeight - height
|
||||
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
|
||||
const minY = CONTENT_WINDOW_GAP
|
||||
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
|
||||
|
||||
const currentPos = positionRef.current
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ type ResizeDirection =
|
||||
*/
|
||||
const EDGE_THRESHOLD = 8
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Hook for handling multi-directional resize functionality of floating panels.
|
||||
* Supports resizing from all 8 directions: 4 corners and 4 edges.
|
||||
@@ -202,14 +205,15 @@ export function useFloatResize({
|
||||
)
|
||||
|
||||
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
|
||||
const maxUpwardDelta = initial.y
|
||||
const minTop = CONTENT_WINDOW_GAP
|
||||
const maxUpwardDelta = initial.y - minTop
|
||||
if (deltaY < -maxUpwardDelta) {
|
||||
deltaY = -maxUpwardDelta
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
|
||||
const maxBottom = window.innerHeight - terminalHeight
|
||||
const maxBottom = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight
|
||||
const initialBottom = initial.y + initial.height
|
||||
const maxDeltaY = maxBottom - initialBottom
|
||||
|
||||
@@ -228,7 +232,7 @@ export function useFloatResize({
|
||||
}
|
||||
|
||||
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
|
||||
const maxRight = window.innerWidth - panelWidth
|
||||
const maxRight = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth
|
||||
const initialRight = initial.x + initial.width
|
||||
const maxDeltaX = maxRight - initialRight
|
||||
|
||||
@@ -303,9 +307,9 @@ export function useFloatResize({
|
||||
}
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - panelWidth - constrainedWidth
|
||||
const minY = 0
|
||||
const maxY = window.innerHeight - terminalHeight - constrainedHeight
|
||||
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - constrainedWidth
|
||||
const minY = CONTENT_WINDOW_GAP
|
||||
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - constrainedHeight
|
||||
|
||||
const finalX = Math.max(minX, Math.min(maxX, newX))
|
||||
const finalY = Math.max(minY, Math.min(maxY, newY))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user