mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
65 Commits
feat/gener
...
v0.6.17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e00f53e1 | ||
|
|
5c47ea58f8 | ||
|
|
1d7ae906bc | ||
|
|
c4f4e6b48c | ||
|
|
560fa75155 | ||
|
|
1728c370de | ||
|
|
82e58a5082 | ||
|
|
336c065234 | ||
|
|
b3713642b2 | ||
|
|
b9b930bb63 | ||
|
|
f1ead2ed55 | ||
|
|
30377d775b | ||
|
|
d013132d0e | ||
|
|
7b0ce8064a | ||
|
|
0ea73263df | ||
|
|
edc502384b | ||
|
|
e2be99263c | ||
|
|
f6b461ad47 | ||
|
|
e4d35735b1 | ||
|
|
b4064c57fb | ||
|
|
eac41ca105 | ||
|
|
d2c3c1c39e | ||
|
|
8f3e864751 | ||
|
|
23c3072784 | ||
|
|
33fdb11396 | ||
|
|
21156dd54a | ||
|
|
c05e2e0fc8 | ||
|
|
14089f7dbb | ||
|
|
e615816dce | ||
|
|
ca87d7ce29 | ||
|
|
6bebbc5e29 | ||
|
|
7b572f1f61 | ||
|
|
ed9a71f0af | ||
|
|
c78c870fda | ||
|
|
19442f19e2 | ||
|
|
1731a4d7f0 | ||
|
|
9fcd02fd3b | ||
|
|
ff7b5b528c | ||
|
|
30f2d1a0fc | ||
|
|
4bd0731871 | ||
|
|
4f3bc37fe4 | ||
|
|
84d6fdc423 | ||
|
|
4c12914d35 | ||
|
|
e9bdc57616 | ||
|
|
36612ae42a | ||
|
|
1c2c2c65d4 | ||
|
|
ecd3536a72 | ||
|
|
8c0a2e04b1 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
10
README.md
10
README.md
@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
#### Background worker note
|
||||
|
||||
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
|
||||
|
||||
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
5. Start development servers:
|
||||
|
||||
```bash
|
||||
bun run dev:full # Starts both Next.js app and realtime socket server
|
||||
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
|
||||
```
|
||||
|
||||
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
|
||||
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
|
||||
|
||||
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const metadata = {
|
||||
metadataBase: new URL('https://docs.sim.ai'),
|
||||
title: {
|
||||
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||
template: '%s',
|
||||
template: '%s | Sim Docs',
|
||||
},
|
||||
description:
|
||||
'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.',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/** Shared className for primary auth form submit buttons across all auth pages. */
|
||||
export const AUTH_SUBMIT_BTN =
|
||||
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
|
||||
export const AUTH_PRIMARY_CTA_BASE =
|
||||
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
|
||||
/** Full-width variant used for primary auth form submit buttons. */
|
||||
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
|
||||
|
||||
@@ -288,7 +288,6 @@ export default function Collaboration() {
|
||||
width={876}
|
||||
height={480}
|
||||
className='h-full w-auto object-left md:min-w-[100vw]'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
|
||||
@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureToggleItemProps {
|
||||
feature: PermissionFeature
|
||||
enabled: boolean
|
||||
color: string
|
||||
isInView: boolean
|
||||
delay: number
|
||||
textClassName: string
|
||||
transition: Record<string, unknown>
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function FeatureToggleItem({
|
||||
feature,
|
||||
enabled,
|
||||
color,
|
||||
isInView,
|
||||
delay,
|
||||
textClassName,
|
||||
transition,
|
||||
onToggle,
|
||||
}: FeatureToggleItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={`Toggle ${feature.name}`}
|
||||
aria-pressed={enabled}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ ...transition, delay }}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessControlPanel() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-40px' })
|
||||
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
|
||||
duration: 0.3,
|
||||
}}
|
||||
onClick={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
{category.features.map((feature, featIdx) => (
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.05 + (offsetBefore + featIdx) * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
|
||||
transition={{ duration: 0.3 }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
|
||||
<div className='hidden lg:block'>
|
||||
{PERMISSION_CATEGORIES.map((category, catIdx) => (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
{category.label}
|
||||
</span>
|
||||
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{category.features.map((feature, featIdx) => {
|
||||
const enabled = accessState[feature.key]
|
||||
const currentIndex =
|
||||
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
|
||||
(sum, c) => sum + c.features.length,
|
||||
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<FeatureToggleItem
|
||||
key={feature.key}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{
|
||||
delay: 0.1 + currentIndex * 0.04,
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
onClick={() =>
|
||||
feature={feature}
|
||||
enabled={accessState[feature.key]}
|
||||
color={category.color}
|
||||
isInView={isInView}
|
||||
delay={0.1 + currentIndex * 0.04}
|
||||
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onToggle={() =>
|
||||
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
|
||||
}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckboxIcon checked={enabled} color={category.color} />
|
||||
<ProviderPreviewIcon providerId={feature.providerId} />
|
||||
<span
|
||||
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
|
||||
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
|
||||
{timeAgo}
|
||||
</span>
|
||||
|
||||
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
|
||||
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
|
||||
<span className='hidden sm:inline'>
|
||||
<span className='text-[#F6F6F6]/40'> · </span>
|
||||
<span className='text-[#F6F6F6]/60'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ function TrustStrip() {
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SOC 2 & HIPAA
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
Type II · PHI protected →
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@ function TrustStrip() {
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
Open Source
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
|
||||
View on GitHub →
|
||||
</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ function TrustStrip() {
|
||||
<strong className='font-[430] font-season text-small text-white leading-none'>
|
||||
SSO & SCIM
|
||||
</strong>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
Okta, Azure AD, Google
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export default function Enterprise() {
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Audit Trail
|
||||
</h3>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Every action is captured with full actor attribution.
|
||||
</p>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ export default function Enterprise() {
|
||||
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
|
||||
Access Control
|
||||
</h3>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
Restrict providers, surfaces, and tools per group.
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export default function Enterprise() {
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
|
||||
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -221,7 +221,7 @@ export default function Enterprise() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<DemoRequestModal>
|
||||
|
||||
@@ -190,7 +190,6 @@ export default function Features() {
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export function FooterCTA() {
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
aria-label='Submit message'
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
|
||||
|
||||
@@ -26,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
// { label: 'Templates', href: '/templates' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
// { label: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
]
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
|
||||
|
||||
interface BlogImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
|
||||
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
onClick={() => setIsLightboxOpen(true)}
|
||||
/>
|
||||
<Lightbox
|
||||
isOpen={isLightboxOpen}
|
||||
onClose={() => setIsLightboxOpen(false)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (overlayRef.current && event.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-label='Image viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
|
||||
loading='lazy'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Partner Program',
|
||||
description:
|
||||
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Partner Program | Sim',
|
||||
description: 'Join the Sim partner program.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
const PARTNER_TIERS = [
|
||||
{
|
||||
name: 'Certified Partner',
|
||||
badge: 'Entry',
|
||||
color: '#3A3A3A',
|
||||
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
|
||||
perks: [
|
||||
'Official partner badge',
|
||||
'Listed in partner directory',
|
||||
'Early access to new features',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Silver Partner',
|
||||
badge: 'Growth',
|
||||
color: '#5A5A5A',
|
||||
requirements: [
|
||||
'All Certified requirements',
|
||||
'3+ active client deployments',
|
||||
'Sim Academy advanced certification',
|
||||
],
|
||||
perks: [
|
||||
'All Certified perks',
|
||||
'Dedicated partner Slack channel',
|
||||
'Co-marketing opportunities',
|
||||
'Priority support',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Gold Partner',
|
||||
badge: 'Premier',
|
||||
color: '#8B7355',
|
||||
requirements: [
|
||||
'All Silver requirements',
|
||||
'10+ active client deployments',
|
||||
'Sim solutions architect certification',
|
||||
],
|
||||
perks: [
|
||||
'All Silver perks',
|
||||
'Revenue share program',
|
||||
'Joint case studies',
|
||||
'Dedicated partner success manager',
|
||||
'Influence product roadmap',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HOW_IT_WORKS = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Sign up & complete Sim Academy',
|
||||
description:
|
||||
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Build & deploy real solutions',
|
||||
description:
|
||||
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Get certified & grow',
|
||||
description:
|
||||
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
|
||||
},
|
||||
]
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
icon: '🎓',
|
||||
title: 'Interactive Learning',
|
||||
description:
|
||||
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Co-Marketing',
|
||||
description:
|
||||
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
|
||||
},
|
||||
{
|
||||
icon: '💰',
|
||||
title: 'Revenue Share',
|
||||
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Early Access',
|
||||
description:
|
||||
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
|
||||
},
|
||||
{
|
||||
icon: '🛠️',
|
||||
title: 'Technical Support',
|
||||
description:
|
||||
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
|
||||
},
|
||||
{
|
||||
icon: '📣',
|
||||
title: 'Community',
|
||||
description:
|
||||
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
|
||||
},
|
||||
]
|
||||
|
||||
export default async function PartnersPage() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
|
||||
>
|
||||
<header>
|
||||
<Navbar logoOnly={false} blogPosts={blogPosts} />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner Program
|
||||
</div>
|
||||
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
|
||||
Build the future
|
||||
<br />
|
||||
of AI automation
|
||||
</h1>
|
||||
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
|
||||
recognition in the growing ecosystem of AI workflow builders.
|
||||
</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
{/* TODO: Uncomment when academy is public */}
|
||||
{/* <Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link> */}
|
||||
<a
|
||||
href='#how-it-works'
|
||||
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Why partner with Sim
|
||||
</div>
|
||||
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
|
||||
<div className='mb-3 text-[24px]'>{b.icon}</div>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
|
||||
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-4xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
How it works
|
||||
</div>
|
||||
<div className='space-y-10'>
|
||||
{HOW_IT_WORKS.map((step) => (
|
||||
<div key={step.step} className='flex gap-8'>
|
||||
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
|
||||
{step.step}
|
||||
</div>
|
||||
<div className='pt-2'>
|
||||
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
|
||||
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Partner tiers */}
|
||||
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Partner tiers
|
||||
</div>
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{PARTNER_TIERS.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
|
||||
>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
|
||||
<span
|
||||
className='rounded-full px-2.5 py-0.5 text-[11px]'
|
||||
style={{
|
||||
backgroundColor: `${tier.color}33`,
|
||||
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
|
||||
border: `1px solid ${tier.color}`,
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
|
||||
Requirements
|
||||
</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.requirements.map((r) => (
|
||||
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto'>
|
||||
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
|
||||
<ul className='space-y-1.5'>
|
||||
{tier.perks.map((p) => (
|
||||
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
|
||||
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className='px-[80px] py-[100px]'>
|
||||
<div className='mx-auto max-w-3xl text-center'>
|
||||
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
</p>
|
||||
{/* TODO: Uncomment when academy is public */}
|
||||
{/* <Link
|
||||
href='/academy'
|
||||
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
|
||||
>
|
||||
Start Sim Academy →
|
||||
</Link> */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/form') ||
|
||||
pathname.startsWith('/oauth')
|
||||
|
||||
const isDarkModePage = pathname.startsWith('/academy')
|
||||
|
||||
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={isLightModePage ? 'light' : undefined}
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
--auth-primary-btn-bg: #ffffff;
|
||||
--auth-primary-btn-border: #ffffff;
|
||||
--auth-primary-btn-text: #000000;
|
||||
--auth-primary-btn-hover-bg: #e0e0e0;
|
||||
--auth-primary-btn-hover-border: #e0e0e0;
|
||||
--auth-primary-btn-hover-text: #000000;
|
||||
|
||||
/* z-index scale for layered UI
|
||||
Popover must be above modal so dropdowns inside modals render correctly */
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCompletedLessons } from '@/lib/academy/local-progress'
|
||||
import type { Course } from '@/lib/academy/types'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
|
||||
|
||||
interface CourseProgressProps {
|
||||
course: Course
|
||||
courseSlug: string
|
||||
}
|
||||
|
||||
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
|
||||
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
|
||||
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
setCompletedIds(getCompletedLessons())
|
||||
}, [])
|
||||
const { data: session } = useSession()
|
||||
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
|
||||
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
|
||||
const certificate = fetchedCert ?? issuedCert
|
||||
|
||||
const allLessons = course.modules.flatMap((m) => m.lessons)
|
||||
const totalLessons = allLessons.length
|
||||
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
|
||||
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{completedCount > 0 && (
|
||||
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
|
||||
<div className='mb-2 flex items-center justify-between text-[13px]'>
|
||||
<span className='text-[#999]'>Your progress</span>
|
||||
<span className='text-[#ECECEC]'>
|
||||
{completedCount}/{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
|
||||
<div
|
||||
className='h-full rounded-full bg-[#ECECEC] transition-all'
|
||||
style={{ width: `${percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl space-y-10'>
|
||||
{course.modules.map((mod, modIndex) => (
|
||||
<div key={mod.id}>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
|
||||
<div className='h-px flex-1 bg-[#2A2A2A]' />
|
||||
</div>
|
||||
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
|
||||
<div className='space-y-2'>
|
||||
{mod.lessons.map((lesson) => (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/academy/${courseSlug}/${lesson.slug}`}
|
||||
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
|
||||
>
|
||||
{completedIds.has(lesson.id) ? (
|
||||
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
|
||||
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
|
||||
{lesson.videoDurationSeconds && (
|
||||
<span className='text-[#555] text-[12px]'>
|
||||
{Math.round(lesson.videoDurationSeconds / 60)} min
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{totalLessons > 0 && completedCount === totalLessons && (
|
||||
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
|
||||
{certificate ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
|
||||
<p className='font-mono text-[#666] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/academy/certificate/${certificate.certificateNumber}`}
|
||||
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
|
||||
>
|
||||
View certificate
|
||||
<ExternalLink className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
|
||||
<div>
|
||||
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
|
||||
<p className='text-[#666] text-[13px]'>
|
||||
{session
|
||||
? error
|
||||
? 'Something went wrong. Try again.'
|
||||
: 'Claim your certificate of completion.'
|
||||
: 'Sign in to claim your certificate.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{session ? (
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
issueCertificate({
|
||||
courseId: course.id,
|
||||
completedLessonIds: [...completedIds],
|
||||
})
|
||||
}
|
||||
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
|
||||
>
|
||||
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
|
||||
{isPending ? 'Issuing…' : 'Get certificate'}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href='/login'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Clock, GraduationCap } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { COURSES, getCourse } from '@/lib/academy/content'
|
||||
import { CourseProgress } from './components/course-progress'
|
||||
|
||||
interface CourseDetailPageProps {
|
||||
params: Promise<{ courseSlug: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return COURSES.map((course) => ({ courseSlug: course.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
if (!course) return { title: 'Course Not Found' }
|
||||
return {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
|
||||
const { courseSlug } = await params
|
||||
const course = getCourse(courseSlug)
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
|
||||
>
|
||||
← All courses
|
||||
</Link>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
|
||||
{course.title}
|
||||
</h1>
|
||||
{course.description && (
|
||||
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3.5 w-3.5' />
|
||||
{course.estimatedMinutes} min total
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<GraduationCap className='h-3.5 w-3.5' />
|
||||
Certificate upon completion
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CourseProgress course={course} courseSlug={courseSlug} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { cache } from 'react'
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { AcademyCertificate } from '@/lib/academy/types'
|
||||
|
||||
interface CertificatePageProps {
|
||||
params: Promise<{ certificateNumber: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
if (!certificate) return { title: 'Certificate Not Found' }
|
||||
return {
|
||||
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
|
||||
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCertificate = cache(
|
||||
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
return (row as unknown as AcademyCertificate) ?? null
|
||||
}
|
||||
)
|
||||
|
||||
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
|
||||
}
|
||||
|
||||
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-5 py-3.5'>
|
||||
<span className='text-[#666] text-[13px]'>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function CertificatePage({ params }: CertificatePageProps) {
|
||||
const { certificateNumber } = await params
|
||||
const certificate = await fetchCertificate(certificateNumber)
|
||||
|
||||
if (!certificate) notFound()
|
||||
|
||||
return (
|
||||
<main className='flex flex-1 items-center justify-center px-6 py-20'>
|
||||
<div className='w-full max-w-2xl'>
|
||||
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
|
||||
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
|
||||
Certificate of Completion
|
||||
</div>
|
||||
|
||||
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
|
||||
{certificate.metadata?.courseTitle}
|
||||
</h1>
|
||||
|
||||
{certificate.metadata?.recipientName && (
|
||||
<p className='mb-6 text-[#999] text-[16px]'>
|
||||
Awarded to{' '}
|
||||
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{certificate.status === 'active' ? (
|
||||
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px]'>Verified</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
|
||||
<XCircle className='h-4 w-4' />
|
||||
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
|
||||
<MetaRow label='Certificate number'>
|
||||
<span className='font-mono text-[#ECECEC] text-[13px]'>
|
||||
{certificate.certificateNumber}
|
||||
</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Issued'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
|
||||
</MetaRow>
|
||||
<MetaRow label='Status'>
|
||||
<span
|
||||
className={`text-[13px] capitalize ${
|
||||
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
|
||||
}`}
|
||||
>
|
||||
{certificate.status}
|
||||
</span>
|
||||
</MetaRow>
|
||||
{certificate.expiresAt && (
|
||||
<MetaRow label='Expires'>
|
||||
<span className='text-[#ECECEC] text-[13px]'>
|
||||
{formatDate(certificate.expiresAt)}
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='mt-5 text-center text-[#555] text-[13px]'>
|
||||
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
|
||||
{certificate.metadata?.courseTitle} program.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
{children}
|
||||
<Footer hideCTA />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AcademyNotFound() {
|
||||
return (
|
||||
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
|
||||
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
|
||||
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
|
||||
<p className='mb-8 text-[#666] text-[15px]'>
|
||||
That course or lesson doesn't exist in the Academy.
|
||||
</p>
|
||||
<Link
|
||||
href='/academy'
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
|
||||
>
|
||||
Back to Academy
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookOpen, Clock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { COURSES } from '@/lib/academy/content'
|
||||
|
||||
export default function AcademyCatalogPage() {
|
||||
return (
|
||||
<main>
|
||||
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
|
||||
Sim Academy
|
||||
</div>
|
||||
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
|
||||
Become a certified
|
||||
<br />
|
||||
Sim partner
|
||||
</h1>
|
||||
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
|
||||
Master AI workflow automation with hands-on interactive exercises on the real Sim
|
||||
canvas. Complete the program to earn your partner certification.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
|
||||
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{COURSES.map((course) => {
|
||||
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
|
||||
return (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/academy/${course.slug}`}
|
||||
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
|
||||
>
|
||||
{course.imageUrl && (
|
||||
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
|
||||
<img
|
||||
src={course.imageUrl}
|
||||
alt={course.title}
|
||||
className='h-full w-full object-cover opacity-80'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex-1'>
|
||||
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
|
||||
{course.title}
|
||||
</h3>
|
||||
{course.description && (
|
||||
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
|
||||
{course.estimatedMinutes && (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{course.estimatedMinutes} min
|
||||
</span>
|
||||
)}
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BookOpen className='h-3 w-3' />
|
||||
{totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
|
||||
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
|
||||
|
||||
interface ExerciseViewProps {
|
||||
lessonId: string
|
||||
exerciseConfig: ExerciseDefinition
|
||||
onComplete?: () => void
|
||||
videoUrl?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the sandbox canvas for an exercise lesson.
|
||||
* Completion is determined client-side by the validation engine and persisted to localStorage.
|
||||
*/
|
||||
export function ExerciseView({
|
||||
lessonId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
}: ExerciseViewProps) {
|
||||
const [completed, setCompleted] = useState(false)
|
||||
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setCompleted(false)
|
||||
}
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
|
||||
setCompleted(true)
|
||||
markLessonComplete(lessonId)
|
||||
onComplete?.()
|
||||
},
|
||||
[lessonId, onComplete]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-col overflow-hidden'>
|
||||
<SandboxCanvasProvider
|
||||
exerciseId={lessonId}
|
||||
exerciseConfig={exerciseConfig}
|
||||
onComplete={handleComplete}
|
||||
videoUrl={videoUrl}
|
||||
description={description}
|
||||
className='flex-1'
|
||||
/>
|
||||
|
||||
{completed && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
|
||||
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
|
||||
<CheckCircle2 className='h-4 w-4' />
|
||||
Exercise complete!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface LessonQuizProps {
|
||||
lessonId: string
|
||||
quizConfig: QuizDefinition
|
||||
onPass?: () => void
|
||||
}
|
||||
|
||||
type Answers = Record<number, number | number[] | boolean>
|
||||
|
||||
interface QuizResult {
|
||||
score: number
|
||||
passed: boolean
|
||||
feedback: Array<{ correct: boolean; explanation?: string }>
|
||||
}
|
||||
|
||||
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
|
||||
const feedback = questions.map((q, i) => {
|
||||
const answer = answers[i]
|
||||
let correct = false
|
||||
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
|
||||
else if (q.type === 'true_false') correct = answer === q.correctAnswer
|
||||
else if (q.type === 'multi_select') {
|
||||
const selected = (answer as number[] | undefined) ?? []
|
||||
correct =
|
||||
selected.length === q.correctIndices.length &&
|
||||
selected.every((v) => q.correctIndices.includes(v))
|
||||
} else {
|
||||
const _exhaustive: never = q
|
||||
void _exhaustive
|
||||
}
|
||||
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
|
||||
})
|
||||
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
|
||||
return { score, passed: score >= passingScore, feedback }
|
||||
}
|
||||
|
||||
const optionBase =
|
||||
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
|
||||
|
||||
/**
|
||||
* Interactive quiz component with per-question feedback and retry support.
|
||||
* Scoring is performed entirely client-side.
|
||||
*/
|
||||
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
|
||||
const [answers, setAnswers] = useState<Answers>({})
|
||||
const [result, setResult] = useState<QuizResult | null>(null)
|
||||
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
|
||||
const [prevLessonId, setPrevLessonId] = useState(lessonId)
|
||||
if (prevLessonId !== lessonId) {
|
||||
setPrevLessonId(lessonId)
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleAnswer = (qi: number, value: number | boolean) => {
|
||||
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
|
||||
}
|
||||
|
||||
const handleMultiSelect = (qi: number, oi: number) => {
|
||||
if (result) return
|
||||
setAnswers((prev) => {
|
||||
const current = (prev[qi] as number[] | undefined) ?? []
|
||||
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
|
||||
return { ...prev, [qi]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const allAnswered = quizConfig.questions.every((q, i) => {
|
||||
if (q.type === 'multi_select')
|
||||
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
|
||||
return answers[i] !== undefined
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
|
||||
setResult(scored)
|
||||
if (scored.passed) {
|
||||
markLessonComplete(lessonId)
|
||||
onPass?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
|
||||
<p className='mt-1 text-[#666] text-[14px]'>
|
||||
Score {quizConfig.passingScore}% or higher to pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quizConfig.questions.map((q, qi) => {
|
||||
const feedback = result?.feedback[qi]
|
||||
const isCorrect = feedback?.correct
|
||||
|
||||
return (
|
||||
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
|
||||
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
|
||||
|
||||
{q.type === 'multiple_choice' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
answers[qi] === oi
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
oi === q.correctIndex &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === oi &&
|
||||
oi !== q.correctIndex &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'true_false' && (
|
||||
<div className='flex gap-3'>
|
||||
{(['True', 'False'] as const).map((label) => {
|
||||
const val = label === 'True'
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type='button'
|
||||
onClick={() => handleAnswer(qi, val)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
|
||||
answers[qi] === val
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
val === q.correctAnswer &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
answers[qi] === val &&
|
||||
val !== q.correctAnswer &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'multi_select' && (
|
||||
<div className='space-y-2'>
|
||||
{q.options.map((opt, oi) => {
|
||||
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
type='button'
|
||||
onClick={() => handleMultiSelect(qi, oi)}
|
||||
disabled={Boolean(result)}
|
||||
className={cn(
|
||||
optionBase,
|
||||
selected
|
||||
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
|
||||
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
|
||||
result &&
|
||||
q.correctIndices.includes(oi) &&
|
||||
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
|
||||
result &&
|
||||
selected &&
|
||||
!q.correctIndices.includes(oi) &&
|
||||
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
|
||||
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
) : (
|
||||
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
|
||||
)}
|
||||
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[8px] border p-5',
|
||||
result.passed
|
||||
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
|
||||
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
|
||||
)}
|
||||
>
|
||||
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
|
||||
<p className='mt-1 text-[13px] opacity-80'>
|
||||
Score: {result.score}% (passing: {quizConfig.passingScore}%)
|
||||
</p>
|
||||
{!result.passed && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setAnswers({})
|
||||
setResult(null)
|
||||
}}
|
||||
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
|
||||
>
|
||||
Submit answers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
interface LessonLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side auth gate for lesson pages.
|
||||
* Redirects unauthenticated users to login before any client JS runs.
|
||||
*/
|
||||
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const { courseSlug, lessonSlug } = await params
|
||||
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getCourse } from '@/lib/academy/content'
|
||||
import { markLessonComplete } from '@/lib/academy/local-progress'
|
||||
import type { Lesson } from '@/lib/academy/types'
|
||||
import { LessonVideo } from '@/app/academy/components/lesson-video'
|
||||
import { ExerciseView } from './components/exercise-view'
|
||||
import { LessonQuiz } from './components/lesson-quiz'
|
||||
|
||||
const navBtnClass =
|
||||
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
|
||||
interface LessonPageProps {
|
||||
params: Promise<{ courseSlug: string; lessonSlug: string }>
|
||||
}
|
||||
|
||||
export default function LessonPage({ params }: LessonPageProps) {
|
||||
const { courseSlug, lessonSlug } = use(params)
|
||||
const course = getCourse(courseSlug)
|
||||
const [exerciseComplete, setExerciseComplete] = useState(false)
|
||||
const [quizComplete, setQuizComplete] = useState(false)
|
||||
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
|
||||
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
|
||||
if (prevLessonSlug !== lessonSlug) {
|
||||
setPrevLessonSlug(lessonSlug)
|
||||
setExerciseComplete(false)
|
||||
setQuizComplete(false)
|
||||
}
|
||||
|
||||
const allLessons = useMemo<Lesson[]>(
|
||||
() => course?.modules.flatMap((m) => m.lessons) ?? [],
|
||||
[course]
|
||||
)
|
||||
|
||||
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
|
||||
const lesson = allLessons[currentIndex]
|
||||
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
|
||||
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
|
||||
|
||||
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
|
||||
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
|
||||
const canAdvance =
|
||||
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
|
||||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
|
||||
? exerciseComplete && quizComplete
|
||||
: lesson?.exerciseConfig
|
||||
? exerciseComplete
|
||||
: quizComplete)
|
||||
|
||||
const isUngatedLesson =
|
||||
lesson?.lessonType === 'video' ||
|
||||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
|
||||
|
||||
useEffect(() => {
|
||||
if (isUngatedLesson && lesson) {
|
||||
markLessonComplete(lesson.id)
|
||||
}
|
||||
}, [lesson?.id, isUngatedLesson])
|
||||
|
||||
if (!course || !lesson) {
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
|
||||
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasVideo = Boolean(lesson.videoUrl)
|
||||
const hasExercise = Boolean(lesson.exerciseConfig)
|
||||
const hasQuiz = Boolean(lesson.quizConfig)
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
|
||||
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
|
||||
<div className='flex items-center gap-3 text-[13px]'>
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim'
|
||||
width={40}
|
||||
height={14}
|
||||
className='opacity-70 invert transition-opacity hover:opacity-100'
|
||||
/>
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
|
||||
Academy
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<Link
|
||||
href={`/academy/${courseSlug}`}
|
||||
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<span className='text-[#333]'>/</span>
|
||||
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{prevLesson ? (
|
||||
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
Course
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link
|
||||
href={`/academy/${courseSlug}/${nextLesson.slug}`}
|
||||
onClick={(e) => {
|
||||
if (!canAdvance) e.preventDefault()
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
|
||||
canAdvance
|
||||
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
|
||||
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||
{lesson.lessonType === 'video' && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'exercise' && hasExercise && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'quiz' && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-2xl'>
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.lessonType === 'mixed' && (
|
||||
<>
|
||||
{hasExercise && (!exerciseComplete || !hasQuiz) && (
|
||||
<ExerciseView
|
||||
lessonId={lesson.id}
|
||||
exerciseConfig={lesson.exerciseConfig!}
|
||||
onComplete={handleExerciseComplete}
|
||||
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
|
||||
description={!hasQuiz ? lesson.description : undefined}
|
||||
/>
|
||||
)}
|
||||
{hasExercise && exerciseComplete && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && hasQuiz && (
|
||||
<div className='flex-1 overflow-y-auto p-8'>
|
||||
<div className='mx-auto w-full max-w-xl space-y-8'>
|
||||
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
|
||||
<LessonQuiz
|
||||
lessonId={lesson.id}
|
||||
quizConfig={lesson.quizConfig!}
|
||||
onPass={handleQuizPass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasExercise && !hasQuiz && hasVideo && (
|
||||
<div className='flex-1 overflow-y-auto p-10'>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
|
||||
{lesson.description && (
|
||||
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
interface LessonVideoProps {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LessonVideo({ url, title }: LessonVideoProps) {
|
||||
const embedUrl = resolveEmbedUrl(url)
|
||||
|
||||
if (!embedUrl) {
|
||||
return (
|
||||
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
|
||||
Video unavailable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEmbedUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
return `https://www.youtube.com/embed${parsed.pathname}`
|
||||
}
|
||||
if (parsed.hostname.includes('youtube.com')) {
|
||||
// Shorts: youtube.com/shorts/VIDEO_ID
|
||||
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
|
||||
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
|
||||
const v = parsed.searchParams.get('v')
|
||||
if (v) return `https://www.youtube.com/embed/${v}`
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'vimeo.com') {
|
||||
const id = parsed.pathname.replace(/^\//, '')
|
||||
if (id) return `https://player.vimeo.com/video/${id}`
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (_e: unknown) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
|
||||
import type {
|
||||
ExerciseBlockState,
|
||||
ExerciseDefinition,
|
||||
ExerciseEdgeState,
|
||||
ValidationResult,
|
||||
} from '@/lib/academy/types'
|
||||
import { validateExercise } from '@/lib/academy/validation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { LessonVideo } from './lesson-video'
|
||||
import { ValidationChecklist } from './validation-checklist'
|
||||
|
||||
const logger = createLogger('SandboxCanvasProvider')
|
||||
|
||||
const SANDBOX_WORKSPACE_ID = 'sandbox'
|
||||
|
||||
interface SandboxCanvasProviderProps {
|
||||
/** Unique ID for this exercise instance */
|
||||
exerciseId: string
|
||||
/** Full exercise configuration */
|
||||
exerciseConfig: ExerciseDefinition
|
||||
/**
|
||||
* Called when all validation rules pass for the first time.
|
||||
* Receives the current canvas state so the caller can persist it.
|
||||
*/
|
||||
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
|
||||
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
|
||||
videoUrl?: string
|
||||
/** Optional description shown below the video (or below checklist if no video) */
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
|
||||
* Looks up each block type in the registry to construct proper sub-block and output maps.
|
||||
*/
|
||||
function buildWorkflowState(
|
||||
initialBlocks: ExerciseBlockState[],
|
||||
initialEdges: ExerciseEdgeState[]
|
||||
): WorkflowState {
|
||||
const blocks: Record<string, BlockState> = {}
|
||||
|
||||
for (const exerciseBlock of initialBlocks) {
|
||||
const config = getBlock(exerciseBlock.type)
|
||||
if (!config) {
|
||||
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlocks: Record<string, SubBlockState> = {}
|
||||
for (const sb of config.subBlocks ?? []) {
|
||||
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
|
||||
subBlocks[sb.id] = {
|
||||
id: sb.id,
|
||||
type: sb.type,
|
||||
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
|
||||
triggerMode: false,
|
||||
preferToolOutputs: true,
|
||||
})
|
||||
|
||||
blocks[exerciseBlock.id] = {
|
||||
id: exerciseBlock.id,
|
||||
type: exerciseBlock.type,
|
||||
name: config.name,
|
||||
position: exerciseBlock.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 0,
|
||||
locked: exerciseBlock.locked ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = initialEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle,
|
||||
targetHandle: e.targetHandle,
|
||||
type: 'default',
|
||||
data: {},
|
||||
}))
|
||||
|
||||
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current canvas state from the workflow store and converts it to
|
||||
* the exercise block/edge format used by the validation engine.
|
||||
*/
|
||||
function readCurrentCanvasState(workflowId: string): {
|
||||
blocks: ExerciseBlockState[]
|
||||
edges: ExerciseEdgeState[]
|
||||
} {
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
|
||||
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
|
||||
const blockValues = storedValues[block.id] ?? {}
|
||||
const subBlocks: Record<string, unknown> = { ...blockValues }
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
}
|
||||
})
|
||||
|
||||
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? undefined,
|
||||
targetHandle: e.targetHandle ?? undefined,
|
||||
}))
|
||||
|
||||
return { blocks, edges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
|
||||
*
|
||||
* - Pre-hydrates workflow stores directly (no API calls)
|
||||
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
|
||||
* - Displays a constrained block toolbar and live validation checklist
|
||||
* - Supports mock execution to simulate workflow runs
|
||||
*/
|
||||
export function SandboxCanvasProvider({
|
||||
exerciseId,
|
||||
exerciseConfig,
|
||||
onComplete,
|
||||
videoUrl,
|
||||
description,
|
||||
className,
|
||||
}: SandboxCanvasProviderProps) {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult>({
|
||||
passed: false,
|
||||
results: [],
|
||||
})
|
||||
const [hintIndex, setHintIndex] = useState(-1)
|
||||
const completedRef = useRef(false)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
const isMockRunningRef = useRef(false)
|
||||
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
|
||||
|
||||
// Stable exercise ID — used as the workflow ID in the stores
|
||||
const workflowId = `sandbox-${exerciseId}`
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
|
||||
setValidationResult((prev) => {
|
||||
if (
|
||||
prev.passed === result.passed &&
|
||||
prev.results.length === result.results.length &&
|
||||
prev.results.every((r, i) => r.passed === result.results[i].passed)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
if (result.passed && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
onCompleteRef.current?.(blocks, edges)
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules])
|
||||
|
||||
useEffect(() => {
|
||||
completedRef.current = false
|
||||
setHintIndex(-1)
|
||||
|
||||
const workflowState = buildWorkflowState(
|
||||
exerciseConfig.initialBlocks ?? [],
|
||||
exerciseConfig.initialEdges ?? []
|
||||
)
|
||||
|
||||
const syntheticMetadata: WorkflowMetadata = {
|
||||
id: workflowId,
|
||||
name: 'Exercise',
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
color: '#3972F6',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
sortOrder: 0,
|
||||
isSandbox: true,
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().replaceWorkflowState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
|
||||
activeWorkflowId: workflowId,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: SANDBOX_WORKSPACE_ID,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info('Sandbox stores hydrated', { workflowId })
|
||||
setIsReady(true)
|
||||
|
||||
// Coalesce rapid store updates so validation runs at most once per animation frame.
|
||||
let rafId: number | null = null
|
||||
const scheduleValidation = () => {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
runValidation()
|
||||
})
|
||||
}
|
||||
|
||||
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
|
||||
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
|
||||
|
||||
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
|
||||
// and returns immediately (no API call). Detect that signal here and run mock execution.
|
||||
const unsubExecution = useExecutionStore.subscribe((state) => {
|
||||
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
|
||||
if (isExec && !isMockRunningRef.current) {
|
||||
void handleMockRunRef.current()
|
||||
}
|
||||
})
|
||||
|
||||
runValidation()
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
unsubWorkflow()
|
||||
unsubSubBlock()
|
||||
unsubExecution()
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflows
|
||||
return {
|
||||
workflows: rest,
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
hydration:
|
||||
state.hydration.workflowId === workflowId
|
||||
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
|
||||
: state.hydration,
|
||||
}
|
||||
})
|
||||
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
|
||||
useSubBlockStore.setState((state) => {
|
||||
const { [workflowId]: _removed, ...rest } = state.workflowValues
|
||||
return { workflowValues: rest }
|
||||
})
|
||||
}
|
||||
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
|
||||
|
||||
const handleMockRun = useCallback(async () => {
|
||||
if (isMockRunningRef.current) return
|
||||
isMockRunningRef.current = true
|
||||
|
||||
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
|
||||
const { blocks, edges } = readCurrentCanvasState(workflowId)
|
||||
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
|
||||
setValidationResult(result)
|
||||
if (!result.passed) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
|
||||
if (plan.length === 0) {
|
||||
isMockRunningRef.current = false
|
||||
setIsExecuting(workflowId, false)
|
||||
return
|
||||
}
|
||||
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
|
||||
const workflowBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
setIsExecuting(workflowId, true)
|
||||
clearWorkflowConsole(workflowId)
|
||||
useTerminalConsoleStore.setState({ isOpen: true })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const step = plan[i]
|
||||
setActiveBlocks(workflowId, new Set([step.blockId]))
|
||||
await new Promise((resolve) => setTimeout(resolve, step.delay))
|
||||
addConsole({
|
||||
workflowId,
|
||||
blockId: step.blockId,
|
||||
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
|
||||
blockType: step.blockType,
|
||||
executionOrder: i,
|
||||
output: step.output,
|
||||
success: true,
|
||||
durationMs: step.delay,
|
||||
})
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(workflowId, false)
|
||||
isMockRunningRef.current = false
|
||||
}
|
||||
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
|
||||
handleMockRunRef.current = handleMockRun
|
||||
|
||||
const handleShowHint = useCallback(() => {
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
if (hints.length === 0) return
|
||||
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
|
||||
}, [exerciseConfig.hints])
|
||||
|
||||
const handlePrevHint = useCallback(() => {
|
||||
setHintIndex((i) => Math.max(i - 1, 0))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hints = exerciseConfig.hints ?? []
|
||||
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
|
||||
|
||||
return (
|
||||
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
|
||||
<GlobalCommandsProvider>
|
||||
<SandboxWorkspacePermissionsProvider>
|
||||
<div className={cn('flex h-full w-full overflow-hidden', className)}>
|
||||
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
|
||||
{(videoUrl || description) && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
|
||||
{description && (
|
||||
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
|
||||
)}
|
||||
<div className='border-[#1F1F1F] border-t' />
|
||||
</div>
|
||||
)}
|
||||
{exerciseConfig.instructions && (
|
||||
<p className='text-[#999] text-[11px] leading-relaxed'>
|
||||
{exerciseConfig.instructions}
|
||||
</p>
|
||||
)}
|
||||
<ValidationChecklist
|
||||
results={validationResult.results}
|
||||
allPassed={validationResult.passed}
|
||||
/>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-2'>
|
||||
{currentHint && (
|
||||
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<span className='font-[430] text-[#666]'>
|
||||
Hint {hintIndex + 1}/{hints.length}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePrevHint}
|
||||
disabled={hintIndex === 0}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Previous hint'
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
disabled={hintIndex === hints.length - 1}
|
||||
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
|
||||
aria-label='Next hint'
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-[#ECECEC]'>{currentHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && hintIndex < 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleShowHint}
|
||||
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
|
||||
>
|
||||
Show hint
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
|
||||
</div>
|
||||
</div>
|
||||
</SandboxWorkspacePermissionsProvider>
|
||||
</GlobalCommandsProvider>
|
||||
</SandboxBlockConstraintsContext.Provider>
|
||||
)
|
||||
}
|
||||
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import type { ValidationRuleResult } from '@/lib/academy/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ValidationChecklistProps {
|
||||
results: ValidationRuleResult[]
|
||||
allPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist showing exercise validation rules and their current pass/fail state.
|
||||
* Rendered inside the exercise sidebar, not as a canvas overlay.
|
||||
*/
|
||||
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-2.5 flex items-center gap-1.5'>
|
||||
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
|
||||
{allPassed && (
|
||||
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{results.map((result, i) => (
|
||||
<li key={i} className='flex items-start gap-2'>
|
||||
{result.passed ? (
|
||||
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
|
||||
) : (
|
||||
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] leading-tight',
|
||||
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
apps/sim/app/academy/layout.tsx
Normal file
33
apps/sim/app/academy/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
// TODO: Remove notFound() call to make academy pages public once content is ready
|
||||
const ACADEMY_ENABLED = false
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Sim Academy',
|
||||
template: '%s | Sim Academy',
|
||||
},
|
||||
description:
|
||||
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
openGraph: {
|
||||
title: 'Sim Academy',
|
||||
description: 'Become a certified Sim partner.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!ACADEMY_ENABLED) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { db } from '@sim/db'
|
||||
import { academyCertificate, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getCourseById } from '@/lib/academy/content'
|
||||
import type { CertificateMetadata } from '@/lib/academy/types'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
const logger = createLogger('AcademyCertificatesAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const CERT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 5,
|
||||
refillRate: 1,
|
||||
refillIntervalMs: 60 * 60_000, // 1 per hour refill
|
||||
}
|
||||
|
||||
const IssueCertificateSchema = z.object({
|
||||
courseId: z.string(),
|
||||
completedLessonIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/academy/certificates
|
||||
* Issues a certificate for the given course after verifying all lessons are completed.
|
||||
* Completion is client-attested: the client sends completed lesson IDs and the server
|
||||
* validates them against the full lesson list for the course.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { allowed } = await rateLimiter.checkRateLimitDirect(
|
||||
`academy:cert:${session.user.id}`,
|
||||
CERT_RATE_LIMIT
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = IssueCertificateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { courseId, completedLessonIds } = parsed.data
|
||||
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify all lessons in the course are reported as completed
|
||||
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||
const completedSet = new Set(completedLessonIds)
|
||||
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
|
||||
if (incomplete.length > 0) {
|
||||
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
|
||||
}
|
||||
|
||||
const [existing, learner] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
])
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'active') {
|
||||
return NextResponse.json({ certificate: existing })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const certificateNumber = generateCertificateNumber()
|
||||
const metadata: CertificateMetadata = {
|
||||
recipientName: learner?.name ?? session.user.name ?? 'Partner',
|
||||
courseTitle: course.title,
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.insert(academyCertificate)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
status: 'active',
|
||||
certificateNumber,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
|
||||
if (!certificate) {
|
||||
const [race] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (race?.status === 'active') {
|
||||
return NextResponse.json({ certificate: race })
|
||||
}
|
||||
if (race) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A certificate for this course already exists but is not active.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Certificate issued', {
|
||||
userId: session.user.id,
|
||||
courseId,
|
||||
certificateNumber,
|
||||
})
|
||||
|
||||
return NextResponse.json({ certificate }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to issue certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
|
||||
* Public endpoint for verifying a certificate by its number.
|
||||
*
|
||||
* GET /api/academy/certificates?courseId=...
|
||||
* Authenticated endpoint for looking up the current user's certificate for a course.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const certificateNumber = searchParams.get('certificateNumber')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (certificateNumber) {
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(eq(academyCertificate.certificateNumber, certificateNumber))
|
||||
.limit(1)
|
||||
|
||||
if (!certificate) {
|
||||
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ certificate })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [certificate] = await db
|
||||
.select()
|
||||
.from(academyCertificate)
|
||||
.where(
|
||||
and(
|
||||
eq(academyCertificate.userId, session.user.id),
|
||||
eq(academyCertificate.courseId, courseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ certificate: certificate ?? null })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'certificateNumber or courseId query parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify certificate', { error })
|
||||
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
|
||||
function generateCertificateNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
return `SIM-${year}-${nanoid(8).toUpperCase()}`
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
mockReturning,
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockAuthenticate,
|
||||
mockCreateUnauthorizedResponse,
|
||||
mockCreateBadRequestResponse,
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
mockReturning: vi.fn(),
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockAuthenticate: vi.fn(),
|
||||
mockCreateUnauthorizedResponse: vi.fn(),
|
||||
mockCreateBadRequestResponse: vi.fn(),
|
||||
@@ -81,7 +83,8 @@ describe('Copilot Feedback API Route', () => {
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockResolvedValue([])
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockResolvedValue([])
|
||||
|
||||
mockCreateRequestTracker.mockReturnValue({
|
||||
requestId: 'test-request-id',
|
||||
@@ -386,7 +389,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -397,7 +400,7 @@ edges:
|
||||
expect(responseData.feedback).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all feedback records', async () => {
|
||||
it('should only return feedback records for the authenticated user', async () => {
|
||||
mockAuthenticate.mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
@@ -415,19 +418,8 @@ edges:
|
||||
workflowYaml: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
},
|
||||
{
|
||||
feedbackId: 'feedback-2',
|
||||
userId: 'user-456',
|
||||
chatId: 'chat-2',
|
||||
userQuery: 'Query 2',
|
||||
agentResponse: 'Response 2',
|
||||
isPositive: false,
|
||||
feedback: 'Not helpful',
|
||||
workflowYaml: 'yaml: content',
|
||||
createdAt: new Date('2024-01-02'),
|
||||
},
|
||||
]
|
||||
mockFrom.mockResolvedValueOnce(mockFeedback)
|
||||
mockWhere.mockResolvedValueOnce(mockFeedback)
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -435,9 +427,14 @@ edges:
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedback).toHaveLength(2)
|
||||
expect(responseData.feedback).toHaveLength(1)
|
||||
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
|
||||
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
|
||||
expect(responseData.feedback[0].userId).toBe('user-123')
|
||||
|
||||
// Verify the where clause was called with the authenticated user's ID
|
||||
const { eq } = await import('drizzle-orm')
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
@@ -446,7 +443,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -462,7 +459,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotFeedback } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -109,7 +110,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
/**
|
||||
* GET /api/copilot/feedback
|
||||
* Get all feedback records (for analytics)
|
||||
* Get feedback records for the authenticated user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
@@ -123,7 +124,7 @@ export async function GET(req: NextRequest) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// Get all feedback records
|
||||
// Get feedback records for the authenticated user only
|
||||
const feedbackRecords = await db
|
||||
.select({
|
||||
feedbackId: copilotFeedback.feedbackId,
|
||||
@@ -137,6 +138,7 @@ export async function GET(req: NextRequest) {
|
||||
createdAt: copilotFeedback.createdAt,
|
||||
})
|
||||
.from(copilotFeedback)
|
||||
.where(eq(copilotFeedback.userId, authenticatedUserId))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotTrainingExamplesAPI')
|
||||
@@ -16,6 +20,11 @@ const TrainingExampleSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const baseUrl = env.AGENT_INDEXER_URL
|
||||
if (!baseUrl) {
|
||||
logger.error('Missing AGENT_INDEXER_URL environment variable')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotTrainingAPI')
|
||||
@@ -22,6 +26,11 @@ const TrainingDataSchema = z.object({
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = env.AGENT_INDEXER_URL
|
||||
if (!baseUrl) {
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: mockExecuteInE2B,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: false,
|
||||
isE2bEnabled: false,
|
||||
isProd: false,
|
||||
isDev: false,
|
||||
isTest: true,
|
||||
}))
|
||||
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { POST } from '@/app/api/function/execute/route'
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('Knowledge Connector By ID API Route', () => {
|
||||
.mockReturnValueOnce(mockDbChain)
|
||||
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
|
||||
.mockReturnValueOnce(mockDbChain)
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
|
||||
@@ -292,7 +292,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const connectorDocuments = await db.transaction(async (tx) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
|
||||
|
||||
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
|
||||
|
||||
const docs = await tx
|
||||
@@ -306,10 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
)
|
||||
|
||||
const documentIds = docs.map((doc) => doc.id)
|
||||
if (documentIds.length > 0) {
|
||||
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
|
||||
await tx.delete(document).where(inArray(document.id, documentIds))
|
||||
if (deleteDocuments) {
|
||||
const documentIds = docs.map((doc) => doc.id)
|
||||
if (documentIds.length > 0) {
|
||||
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
|
||||
await tx.delete(document).where(inArray(document.id, documentIds))
|
||||
}
|
||||
}
|
||||
|
||||
const deletedConnectors = await tx
|
||||
@@ -328,16 +333,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
throw new Error('Connector not found')
|
||||
}
|
||||
|
||||
return docs
|
||||
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
|
||||
})
|
||||
|
||||
await deleteDocumentStorageFiles(connectorDocuments, requestId)
|
||||
if (deleteDocuments) {
|
||||
await Promise.all([
|
||||
deletedDocs.length > 0
|
||||
? deleteDocumentStorageFiles(deletedDocs, requestId)
|
||||
: Promise.resolve(),
|
||||
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
|
||||
logger.info(
|
||||
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
@@ -349,7 +361,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
resourceId: connectorId,
|
||||
resourceName: existingConnector[0].connectorType,
|
||||
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
|
||||
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
|
||||
metadata: {
|
||||
knowledgeBaseId,
|
||||
documentsDeleted: deleteDocuments ? docCount : 0,
|
||||
documentsKept: deleteDocuments ? 0 : docCount,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const GetChunksQuerySchema = z.object({
|
||||
enabled: z.enum(['true', 'false', 'all']).optional().default('all'),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
offset: z.coerce.number().min(0).optional().default(0),
|
||||
sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
|
||||
})
|
||||
|
||||
const CreateChunkSchema = z.object({
|
||||
@@ -88,6 +90,8 @@ export async function GET(
|
||||
enabled: searchParams.get('enabled') || undefined,
|
||||
limit: searchParams.get('limit') || undefined,
|
||||
offset: searchParams.get('offset') || undefined,
|
||||
sortBy: searchParams.get('sortBy') || undefined,
|
||||
sortOrder: searchParams.get('sortOrder') || undefined,
|
||||
})
|
||||
|
||||
const result = await queryChunks(documentId, queryParams, requestId)
|
||||
|
||||
@@ -61,6 +61,21 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify caller is either an org member or the invitee
|
||||
const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase()
|
||||
|
||||
if (!isInvitee) {
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const org = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('ImapMailboxesAPI')
|
||||
|
||||
@@ -9,7 +10,6 @@ interface ImapMailboxRequest {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
rejectUnauthorized: boolean
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as ImapMailboxRequest
|
||||
const { host, port, secure, rejectUnauthorized, username, password } = body
|
||||
const { host, port, secure, username, password } = body
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -31,8 +31,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
host: hostValidation.resolvedIP!,
|
||||
servername: host,
|
||||
port: port || 993,
|
||||
secure: secure ?? true,
|
||||
auth: {
|
||||
@@ -40,7 +46,7 @@ export async function POST(request: NextRequest) {
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
@@ -79,21 +85,12 @@ export async function POST(request: NextRequest) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error fetching IMAP mailboxes:', errorMessage)
|
||||
|
||||
let userMessage = 'Failed to connect to IMAP server'
|
||||
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
|
||||
if (
|
||||
errorMessage.includes('AUTHENTICATIONFAILED') ||
|
||||
errorMessage.includes('Invalid credentials')
|
||||
) {
|
||||
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
|
||||
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
|
||||
userMessage = 'Could not find IMAP server. Please check the hostname.'
|
||||
} else if (errorMessage.includes('ECONNREFUSED')) {
|
||||
userMessage = 'Connection refused. Please check the port and SSL settings.'
|
||||
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
|
||||
userMessage =
|
||||
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage = 'Connection timed out. Please check your network and server settings.'
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const S_IFMT = 0o170000
|
||||
const S_IFDIR = 0o040000
|
||||
@@ -91,16 +92,23 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
|
||||
* Creates an SSH connection for SFTP using the provided configuration.
|
||||
* Uses ssh2 library defaults which align with OpenSSH standards.
|
||||
*/
|
||||
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
throw new Error('Host is required. Please provide a valid hostname or IP address.')
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
const port = config.port || 22
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasPassword = config.password && config.password.trim() !== ''
|
||||
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
|
||||
@@ -111,7 +119,7 @@ export function createSftpConnection(config: SftpConnectionConfig): Promise<Clie
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
@@ -56,6 +57,15 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = SmtpSendSchema.parse(body)
|
||||
|
||||
const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost')
|
||||
if (!hostValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SMTP host validation failed`, {
|
||||
host: validatedData.smtpHost,
|
||||
error: hostValidation.error,
|
||||
})
|
||||
return NextResponse.json({ success: false, error: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Sending email via SMTP`, {
|
||||
host: validatedData.smtpHost,
|
||||
port: validatedData.smtpPort,
|
||||
@@ -64,8 +74,13 @@ export async function POST(request: NextRequest) {
|
||||
secure: validatedData.smtpSecure,
|
||||
})
|
||||
|
||||
// Pin the pre-resolved IP to prevent DNS rebinding (TOCTOU) attacks.
|
||||
// Pass resolvedIP as the host so nodemailer connects to the validated address,
|
||||
// and set servername for correct TLS SNI/certificate validation.
|
||||
const pinnedHost = hostValidation.resolvedIP ?? validatedData.smtpHost
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: validatedData.smtpHost,
|
||||
host: pinnedHost,
|
||||
port: validatedData.smtpPort,
|
||||
secure: validatedData.smtpSecure === 'SSL',
|
||||
auth: {
|
||||
@@ -74,12 +89,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
tls:
|
||||
validatedData.smtpSecure === 'None'
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
|
||||
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
|
||||
})
|
||||
|
||||
const contentType = validatedData.contentType || 'text'
|
||||
@@ -189,16 +200,16 @@ export async function POST(request: NextRequest) {
|
||||
if (isNodeError(error)) {
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'SMTP authentication failed - check username and password'
|
||||
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
|
||||
} else if (
|
||||
error.code === 'ECONNECTION' ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ECONNRESET' ||
|
||||
error.code === 'ETIMEDOUT'
|
||||
) {
|
||||
errorMessage = 'Could not connect to SMTP server - check host and port'
|
||||
} else if (error.code === 'ECONNRESET') {
|
||||
errorMessage = 'Connection was reset by SMTP server'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'SMTP server connection timeout'
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SMTP response codes
|
||||
const hasResponseCode = (err: unknown): err is { responseCode: number } => {
|
||||
return typeof err === 'object' && err !== null && 'responseCode' in err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('SSHUtils')
|
||||
|
||||
@@ -108,16 +109,23 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
|
||||
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
|
||||
*/
|
||||
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
|
||||
export async function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
throw new Error('Host is required. Please provide a valid hostname or IP address.')
|
||||
}
|
||||
|
||||
const hostValidation = await validateDatabaseHost(host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client()
|
||||
const port = config.port || 22
|
||||
const host = config.host
|
||||
|
||||
if (!host || host.trim() === '') {
|
||||
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasPassword = config.password && config.password.trim() !== ''
|
||||
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
|
||||
@@ -128,7 +136,7 @@ export function createSSHConnection(config: SSHConnectionConfig): Promise<Client
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
updateApiKeyLastUsed,
|
||||
} from '@/lib/api-key/service'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMiddleware')
|
||||
@@ -81,11 +80,6 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const internalSecret = request.headers.get('X-Internal-Secret')
|
||||
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
|
||||
return { workflow }
|
||||
}
|
||||
|
||||
let apiKeyHeader = null
|
||||
for (const [key, value] of request.headers.entries()) {
|
||||
if (key.toLowerCase() === 'x-api-key' && value) {
|
||||
|
||||
@@ -79,6 +79,22 @@ vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/emails', () => ({
|
||||
WorkspaceInvitationEmail: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/utils', () => ({
|
||||
getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'),
|
||||
}))
|
||||
|
||||
vi.mock('@react-email/render', () => ({
|
||||
render: vi.fn().mockResolvedValue('<html></html>'),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: () => mockDbSelect(),
|
||||
@@ -171,9 +187,10 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
|
||||
describe('GET /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return invitation details when called without token', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
it('should return invitation details when caller is the invitee', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: 'invited@example.com' })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
@@ -191,6 +208,43 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return invitation details when caller is a workspace admin', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({
|
||||
id: 'invitation-789',
|
||||
email: 'invited@example.com',
|
||||
status: 'pending',
|
||||
workspaceName: 'Test Workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 403 when caller is neither invitee nor workspace admin', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
})
|
||||
|
||||
it('should redirect to login when unauthenticated with token', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
|
||||
@@ -198,6 +198,15 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase()
|
||||
|
||||
if (!isInvitee) {
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
|
||||
@@ -293,7 +293,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
|
||||
<button
|
||||
onClick={() => handleVerifyOtp()}
|
||||
disabled={otpValue.length !== 6 || isVerifyingOtp}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{isVerifyingOtp ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
interface FormErrorStateProps {
|
||||
@@ -12,10 +13,7 @@ export function FormErrorState({ error }: FormErrorStateProps) {
|
||||
|
||||
return (
|
||||
<StatusPageLayout title='Form Unavailable' description={error}>
|
||||
<button
|
||||
onClick={() => router.push('/workspace')}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={() => router.push('/workspace')} className={AUTH_SUBMIT_BTN}>
|
||||
Return to Workspace
|
||||
</button>
|
||||
</StatusPageLayout>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
@@ -75,7 +76,7 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!password.trim() || isSubmitting}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
const logger = createLogger('FormError')
|
||||
@@ -21,10 +22,7 @@ export default function FormError({ error, reset }: FormErrorProps) {
|
||||
title='Something went wrong'
|
||||
description='We encountered an error loading this form. Please try again.'
|
||||
>
|
||||
<button
|
||||
onClick={reset}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={reset} className={AUTH_SUBMIT_BTN}>
|
||||
Try again
|
||||
</button>
|
||||
</StatusPageLayout>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import {
|
||||
@@ -322,11 +323,7 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
|
||||
{isSubmitting ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
|
||||
@@ -55,10 +56,7 @@ export function InviteStatusCard({
|
||||
|
||||
<div className='mt-8 w-full max-w-[410px] space-y-3'>
|
||||
{isExpiredError && (
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={() => router.push('/')} className={`${AUTH_PRIMARY_CTA_BASE} w-full`}>
|
||||
Request New Invitation
|
||||
</button>
|
||||
)}
|
||||
@@ -69,9 +67,9 @@ export function InviteStatusCard({
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
className={cn(
|
||||
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
`${AUTH_PRIMARY_CTA_BASE} w-full`,
|
||||
index !== 0 &&
|
||||
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
|
||||
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
|
||||
)}
|
||||
>
|
||||
{action.loading ? (
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
{/* OneDollarStats Analytics */}
|
||||
<link rel='dns-prefetch' href='https://assets.onedollarstats.com' />
|
||||
<script defer src='https://assets.onedollarstats.com/stonks.js' />
|
||||
|
||||
<PublicEnvScript />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getNavBlogPosts } from '@/lib/blog/registry'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -9,9 +10,6 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export default async function NotFound() {
|
||||
const blogPosts = await getNavBlogPosts()
|
||||
return (
|
||||
@@ -29,10 +27,7 @@ export default async function NotFound() {
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Link
|
||||
href='/'
|
||||
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)]`}
|
||||
>
|
||||
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import Landing from '@/app/(home)/landing'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 3600
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||
title: {
|
||||
absolute: '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 orchestrate agentic workflows.',
|
||||
keywords:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
|
||||
interface UnsubscribeData {
|
||||
@@ -143,10 +144,7 @@ function UnsubscribeContent() {
|
||||
</div>
|
||||
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={() => window.history.back()} className={AUTH_SUBMIT_BTN}>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
@@ -168,10 +166,7 @@ function UnsubscribeContent() {
|
||||
</div>
|
||||
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
@@ -193,10 +188,7 @@ function UnsubscribeContent() {
|
||||
</div>
|
||||
|
||||
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
@@ -222,7 +214,7 @@ function UnsubscribeContent() {
|
||||
<button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{processing ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
@@ -249,7 +241,7 @@ function UnsubscribeContent() {
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
@@ -263,7 +255,7 @@ function UnsubscribeContent() {
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
@@ -277,7 +269,7 @@ function UnsubscribeContent() {
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
|
||||
@@ -16,7 +16,6 @@ const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
|
||||
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
|
||||
|
||||
export function NavTour() {
|
||||
@@ -25,9 +24,6 @@ export function NavTour() {
|
||||
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: navTourSteps,
|
||||
storageKey: NAV_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 1200,
|
||||
resettable: true,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
disabled: isWorkflowPage,
|
||||
|
||||
@@ -12,17 +12,11 @@ const FADE_OUT_MS = 80
|
||||
interface UseTourOptions {
|
||||
/** Tour step definitions */
|
||||
steps: Step[]
|
||||
/** localStorage key for completion persistence */
|
||||
storageKey: string
|
||||
/** Delay before auto-starting the tour (ms) */
|
||||
autoStartDelay?: number
|
||||
/** Whether this tour can be reset/retriggered */
|
||||
resettable?: boolean
|
||||
/** Custom event name to listen for manual triggers */
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
|
||||
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -41,49 +35,14 @@ interface UseTourReturn {
|
||||
handleCallback: (data: CallBackProps) => void
|
||||
}
|
||||
|
||||
function isTourCompleted(storageKey: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markTourCompleted(storageKey: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, 'true')
|
||||
} catch {
|
||||
logger.warn('Failed to persist tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
function clearTourCompletion(storageKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
logger.warn('Failed to clear tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tours have already attempted auto-start in this page session.
|
||||
* Module-level so it survives component remounts (e.g. navigating between
|
||||
* workflows remounts WorkflowTour), while still resetting on full page reload.
|
||||
*/
|
||||
const autoStartAttempted = new Set<string>()
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
* Handles auto-start on first visit, localStorage persistence,
|
||||
* manual triggering via custom events, and coordinated fade
|
||||
* Handles manual triggering via custom events and coordinated fade
|
||||
* transitions between steps to prevent layout shift.
|
||||
*/
|
||||
export function useTour({
|
||||
steps,
|
||||
storageKey,
|
||||
autoStartDelay = 1200,
|
||||
resettable = false,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
disabled = false,
|
||||
@@ -94,15 +53,10 @@ export function useTour({
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const disabledRef = useRef(disabled)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Schedules a two-frame rAF to reveal the tooltip after the browser
|
||||
* finishes repositioning. Stores the outer frame ID in `rafRef` so
|
||||
@@ -137,8 +91,7 @@ export function useTour({
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey, cancelPendingTransitions])
|
||||
}, [cancelPendingTransitions])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
@@ -164,40 +117,17 @@ export function useTour({
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
stopTour()
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName, cancelPendingTransitions])
|
||||
|
||||
/** Auto-start on first visit (once per page session per tour) */
|
||||
useEffect(() => {
|
||||
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (disabledRef.current) return
|
||||
|
||||
autoStartAttempted.add(storageKey)
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
scheduleReveal()
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
|
||||
}, [disabled, run, tourName, stopTour])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
if (!triggerEvent || !resettable) return
|
||||
if (!triggerEvent) return
|
||||
|
||||
const handleTrigger = () => {
|
||||
setRun(false)
|
||||
clearTourCompletion(storageKey)
|
||||
setTourKey((k) => k + 1)
|
||||
|
||||
if (retriggerTimerRef.current) {
|
||||
@@ -222,7 +152,7 @@ export function useTour({
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
|
||||
}, [triggerEvent, tourName, scheduleReveal])
|
||||
|
||||
/** Clean up all pending async work on unmount */
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,19 +15,15 @@ const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
|
||||
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
|
||||
|
||||
/**
|
||||
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
|
||||
* Runs on first workflow visit and can be retriggered via "Take a tour".
|
||||
* Triggered via "Take a tour" in the sidebar menu.
|
||||
*/
|
||||
export function WorkflowTour() {
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: workflowTourSteps,
|
||||
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 800,
|
||||
resettable: true,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -19,8 +19,6 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
const SEARCH_ICON = (
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
)
|
||||
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
@@ -67,7 +65,12 @@ export interface SearchConfig {
|
||||
interface ResourceOptionsBarProps {
|
||||
search?: SearchConfig
|
||||
sort?: SortConfig
|
||||
/** Popover content — renders inside a Popover (used by logs, etc.) */
|
||||
filter?: ReactNode
|
||||
/** When provided, Filter button acts as a toggle instead of opening a Popover */
|
||||
onFilterToggle?: () => void
|
||||
/** Whether the filter is currently active (highlights the toggle button) */
|
||||
filterActive?: boolean
|
||||
filterTags?: FilterTag[]
|
||||
extras?: ReactNode
|
||||
}
|
||||
@@ -76,10 +79,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
search,
|
||||
sort,
|
||||
filter,
|
||||
onFilterToggle,
|
||||
filterActive,
|
||||
filterTags,
|
||||
extras,
|
||||
}: ResourceOptionsBarProps) {
|
||||
const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0)
|
||||
const hasContent =
|
||||
search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0)
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
@@ -88,22 +94,39 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
{search && <SearchSection search={search} />}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{extras}
|
||||
{filterTags?.map((tag) => (
|
||||
{filterTags?.map((tag, i) => (
|
||||
<Button
|
||||
key={tag.label}
|
||||
key={`${tag.label}-${i}`}
|
||||
variant='subtle'
|
||||
className='px-2 py-1 text-caption'
|
||||
className='max-w-[200px] px-2 py-1 text-caption'
|
||||
onClick={tag.onRemove}
|
||||
>
|
||||
{tag.label}
|
||||
<span className='ml-1 text-[var(--text-icon)] text-micro'>✕</span>
|
||||
<span className='truncate'>{tag.label}</span>
|
||||
<span className='ml-1 shrink-0 text-[var(--text-icon)] text-micro'>✕</span>
|
||||
</Button>
|
||||
))}
|
||||
{filter && (
|
||||
{onFilterToggle ? (
|
||||
<Button
|
||||
variant='subtle'
|
||||
className={cn(
|
||||
'px-2 py-1 text-caption',
|
||||
filterActive && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={onFilterToggle}
|
||||
>
|
||||
<ListFilter
|
||||
className={cn(
|
||||
'mr-1.5 h-[14px] w-[14px]',
|
||||
filterActive ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
|
||||
)}
|
||||
/>
|
||||
Filter
|
||||
</Button>
|
||||
) : filter ? (
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<Button variant='subtle' className='px-2 py-1 text-caption'>
|
||||
{FILTER_ICON}
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</Button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
@@ -111,15 +134,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
<PopoverPrimitive.Content
|
||||
align='start'
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
'z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
)}
|
||||
className='z-50 w-fit rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
{filter}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
)}
|
||||
) : null}
|
||||
{sort && <SortDropdown config={sort} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,34 +149,6 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
})
|
||||
|
||||
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
|
||||
const [localValue, setLocalValue] = useState(search.value)
|
||||
|
||||
const lastReportedRef = useRef(search.value)
|
||||
|
||||
if (search.value !== lastReportedRef.current) {
|
||||
setLocalValue(search.value)
|
||||
lastReportedRef.current = search.value
|
||||
}
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = e.target.value
|
||||
setLocalValue(next)
|
||||
search.onChange(next)
|
||||
},
|
||||
[search.onChange]
|
||||
)
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
setLocalValue('')
|
||||
lastReportedRef.current = ''
|
||||
if (search.onClearAll) {
|
||||
search.onClearAll()
|
||||
} else {
|
||||
search.onChange('')
|
||||
}
|
||||
}, [search.onClearAll, search.onChange])
|
||||
|
||||
return (
|
||||
<div className='relative flex flex-1 items-center'>
|
||||
{SEARCH_ICON}
|
||||
@@ -177,8 +170,8 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type='text'
|
||||
value={localValue}
|
||||
onChange={handleInputChange}
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
onKeyDown={search.onKeyDown}
|
||||
onFocus={search.onFocus}
|
||||
onBlur={search.onBlur}
|
||||
@@ -186,11 +179,11 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
|
||||
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
{search.tags?.length || localValue ? (
|
||||
{search.tags?.length || search.value ? (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={handleClearAll}
|
||||
onClick={search.onClearAll ?? (() => search.onChange(''))}
|
||||
>
|
||||
<span className='text-caption'>✕</span>
|
||||
</button>
|
||||
@@ -213,8 +206,19 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='subtle' className='px-2 py-1 text-caption'>
|
||||
{SORT_ICON}
|
||||
<Button
|
||||
variant='subtle'
|
||||
className={cn(
|
||||
'px-2 py-1 text-caption',
|
||||
active && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown
|
||||
className={cn(
|
||||
'mr-1.5 h-[14px] w-[14px]',
|
||||
active ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
|
||||
)}
|
||||
/>
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
@@ -290,6 +291,16 @@ function TextEditor({
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const handleCheckboxToggle = useCallback(
|
||||
(checkboxIndex: number, checked: boolean) => {
|
||||
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
|
||||
if (toggled !== contentRef.current) {
|
||||
handleContentChange(toggled)
|
||||
}
|
||||
},
|
||||
[handleContentChange]
|
||||
)
|
||||
|
||||
const isStreaming = streamingContent !== undefined
|
||||
const revealedContent = useStreamingText(content, isStreaming)
|
||||
|
||||
@@ -392,10 +403,11 @@ function TextEditor({
|
||||
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
|
||||
>
|
||||
<PreviewPanel
|
||||
content={revealedContent}
|
||||
content={isStreaming ? revealedContent : content}
|
||||
mimeType={file.type}
|
||||
filename={file.name}
|
||||
isStreaming={isStreaming}
|
||||
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -421,17 +433,120 @@ const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFil
|
||||
)
|
||||
})
|
||||
|
||||
const ZOOM_MIN = 0.25
|
||||
const ZOOM_MAX = 4
|
||||
const ZOOM_WHEEL_SENSITIVITY = 0.005
|
||||
const ZOOM_BUTTON_FACTOR = 1.2
|
||||
|
||||
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
|
||||
|
||||
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const isDragging = useRef(false)
|
||||
const dragStart = useRef({ x: 0, y: 0 })
|
||||
const offsetAtDragStart = useRef({ x: 0, y: 0 })
|
||||
const offsetRef = useRef(offset)
|
||||
offsetRef.current = offset
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const zoomIn = useCallback(() => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR)), [])
|
||||
const zoomOut = useCallback(() => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR)), [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
|
||||
} else {
|
||||
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
|
||||
}
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
isDragging.current = true
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
offsetAtDragStart.current = offsetRef.current
|
||||
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging.current) return
|
||||
setOffset({
|
||||
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
|
||||
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDragging.current = false
|
||||
if (containerRef.current) containerRef.current.style.cursor = 'grab'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
}, [file.key])
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={file.name}
|
||||
className='max-h-full max-w-full rounded-md object-contain'
|
||||
loading='eager'
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className='pointer-events-none absolute inset-0 flex items-center justify-center'
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={file.name}
|
||||
className='max-h-full max-w-full select-none rounded-md object-contain'
|
||||
draggable={false}
|
||||
loading='eager'
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-sm'
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
|
||||
aria-label='Zoom out'
|
||||
>
|
||||
<ZoomOut className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
|
||||
aria-label='Zoom in'
|
||||
>
|
||||
<ZoomIn className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -703,6 +818,14 @@ function PptxPreview({
|
||||
)
|
||||
}
|
||||
|
||||
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
|
||||
let currentIndex = 0
|
||||
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
|
||||
if (currentIndex++ !== targetIndex) return match
|
||||
return `${prefix}[${checked ? 'x' : ' '}]`
|
||||
})
|
||||
}
|
||||
|
||||
const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
file,
|
||||
}: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { createContext, memo, useContext, useMemo, useRef } from 'react'
|
||||
import type { Components, ExtraProps } from 'react-markdown'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { useAutoScroll } from '@/hooks/use-auto-scroll'
|
||||
@@ -40,6 +42,7 @@ interface PreviewPanelProps {
|
||||
mimeType: string | null
|
||||
filename: string
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}
|
||||
|
||||
export const PreviewPanel = memo(function PreviewPanel({
|
||||
@@ -47,11 +50,18 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
mimeType,
|
||||
filename,
|
||||
isStreaming,
|
||||
onCheckboxToggle,
|
||||
}: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'markdown')
|
||||
return <MarkdownPreview content={content} isStreaming={isStreaming} />
|
||||
return (
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
isStreaming={isStreaming}
|
||||
onCheckboxToggle={onCheckboxToggle}
|
||||
/>
|
||||
)
|
||||
if (previewType === 'html') return <HtmlPreview content={content} />
|
||||
if (previewType === 'csv') return <CsvPreview content={content} />
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
@@ -61,45 +71,51 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
|
||||
const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: any) => (
|
||||
/**
|
||||
* Carries the contentRef and toggle handler from MarkdownPreview down to the
|
||||
* task-list renderers. Only present when the preview is interactive.
|
||||
*/
|
||||
const MarkdownCheckboxCtx = createContext<{
|
||||
contentRef: React.MutableRefObject<string>
|
||||
onToggle: (index: number, checked: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
|
||||
const CheckboxIndexCtx = createContext(-1)
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }: any) => (
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: any) => (
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: any) => (
|
||||
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const isInline = inline || !className?.includes('language-')
|
||||
code: ({
|
||||
className,
|
||||
children,
|
||||
node: _node,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & ExtraProps) => {
|
||||
const isInline = !className?.includes('language-')
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
@@ -121,8 +137,8 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }: any) => <>{children}</>,
|
||||
a: ({ href, children }: any) => (
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
@@ -132,59 +148,175 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }: any) => (
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
em: ({ children }: any) => (
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
|
||||
),
|
||||
blockquote: ({ children }: any) => (
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className='my-6 border-[var(--border)]' />,
|
||||
img: ({ src, alt }: any) => (
|
||||
img: ({ src, alt, node: _node }: React.ComponentPropsWithoutRef<'img'> & ExtraProps) => (
|
||||
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
|
||||
),
|
||||
table: ({ children }: any) => (
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className='my-4 max-w-full overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-[13px]'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
|
||||
tbody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: any) => (
|
||||
thead: ({ children }: { children?: React.ReactNode }) => (
|
||||
<thead className='bg-[var(--surface-2)]'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }: { children?: React.ReactNode }) => (
|
||||
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
|
||||
),
|
||||
th: ({ children }: any) => (
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
|
||||
),
|
||||
}
|
||||
|
||||
function UlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ul'> & ExtraProps) {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function OlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ol'> & ExtraProps) {
|
||||
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
|
||||
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function LiRenderer({
|
||||
className,
|
||||
children,
|
||||
node,
|
||||
}: React.ComponentPropsWithoutRef<'li'> & ExtraProps) {
|
||||
const ctx = useContext(MarkdownCheckboxCtx)
|
||||
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
|
||||
|
||||
if (isTaskItem) {
|
||||
if (ctx) {
|
||||
const offset = node?.position?.start?.offset
|
||||
if (offset === undefined) {
|
||||
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
}
|
||||
const before = ctx.contentRef.current.slice(0, offset)
|
||||
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
|
||||
return (
|
||||
<CheckboxIndexCtx.Provider value={prior ? prior.length : 0}>
|
||||
<li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
</CheckboxIndexCtx.Provider>
|
||||
)
|
||||
}
|
||||
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
|
||||
}
|
||||
|
||||
return <li className='break-words leading-[1.6]'>{children}</li>
|
||||
}
|
||||
|
||||
function InputRenderer({
|
||||
type,
|
||||
checked,
|
||||
node: _node,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'input'> & ExtraProps) {
|
||||
const ctx = useContext(MarkdownCheckboxCtx)
|
||||
const index = useContext(CheckboxIndexCtx)
|
||||
|
||||
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
|
||||
|
||||
const isInteractive = ctx !== null && index >= 0
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked ?? false}
|
||||
onCheckedChange={
|
||||
isInteractive ? (newChecked) => ctx.onToggle(index, Boolean(newChecked)) : undefined
|
||||
}
|
||||
disabled={!isInteractive}
|
||||
size='sm'
|
||||
className='mt-1 shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
ul: UlRenderer,
|
||||
ol: OlRenderer,
|
||||
li: LiRenderer,
|
||||
input: InputRenderer,
|
||||
} satisfies Components
|
||||
|
||||
const MarkdownPreview = memo(function MarkdownPreview({
|
||||
content,
|
||||
isStreaming = false,
|
||||
onCheckboxToggle,
|
||||
}: {
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}) {
|
||||
const { ref: scrollRef } = useAutoScroll(isStreaming)
|
||||
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
|
||||
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const ctxValue = useMemo(
|
||||
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
const committedMarkdown = useMemo(
|
||||
() =>
|
||||
committed ? (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{committed}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
[committed]
|
||||
)
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
@@ -193,7 +325,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Columns2,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Download,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -31,17 +33,22 @@ import {
|
||||
formatFileSize,
|
||||
getFileExtension,
|
||||
getMimeTypeFromExtension,
|
||||
isAudioFileType,
|
||||
isVideoFileType,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
isSupportedExtension,
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
SUPPORTED_DOCUMENT_EXTENSIONS,
|
||||
SUPPORTED_VIDEO_EXTENSIONS,
|
||||
} from '@/lib/uploads/utils/validation'
|
||||
import type {
|
||||
FilterTag,
|
||||
HeaderAction,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
InlineRenameInput,
|
||||
@@ -66,6 +73,7 @@ import {
|
||||
useUploadWorkspaceFile,
|
||||
useWorkspaceFiles,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
@@ -86,7 +94,6 @@ const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'type', header: 'Type' },
|
||||
{ id: 'created', header: 'Created' },
|
||||
{ id: 'owner', header: 'Owner' },
|
||||
{ id: 'updated', header: 'Last Updated' },
|
||||
]
|
||||
|
||||
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -161,16 +168,14 @@ export function Files() {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(value)
|
||||
}, 200)
|
||||
}, [])
|
||||
const debouncedSearchTerm = useDebounce(inputValue, 200)
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState<string[]>([])
|
||||
const [sizeFilter, setSizeFilter] = useState<string[]>([])
|
||||
const [uploadedByFilter, setUploadedByFilter] = useState<string[]>([])
|
||||
|
||||
const [creatingFile, setCreatingFile] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
@@ -206,10 +211,60 @@ export function Files() {
|
||||
selectedFileRef.current = selectedFile
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!debouncedSearchTerm) return files
|
||||
const q = debouncedSearchTerm.toLowerCase()
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}, [files, debouncedSearchTerm])
|
||||
let result = debouncedSearchTerm
|
||||
? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
|
||||
: files
|
||||
|
||||
if (typeFilter.length > 0) {
|
||||
result = result.filter((f) => {
|
||||
const ext = getFileExtension(f.name)
|
||||
if (typeFilter.includes('document') && isSupportedExtension(ext)) return true
|
||||
if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true
|
||||
if (typeFilter.includes('video') && isVideoFileType(f.type)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (sizeFilter.length > 0) {
|
||||
result = result.filter((f) => {
|
||||
if (sizeFilter.includes('small') && f.size < 1_048_576) return true
|
||||
if (sizeFilter.includes('medium') && f.size >= 1_048_576 && f.size <= 10_485_760)
|
||||
return true
|
||||
if (sizeFilter.includes('large') && f.size > 10_485_760) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (uploadedByFilter.length > 0) {
|
||||
result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy))
|
||||
}
|
||||
|
||||
const col = activeSort?.column ?? 'created'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'size':
|
||||
cmp = a.size - b.size
|
||||
break
|
||||
case 'type':
|
||||
cmp = formatFileType(a.type, a.name).localeCompare(formatFileType(b.type, b.name))
|
||||
break
|
||||
case 'created':
|
||||
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
|
||||
break
|
||||
case 'owner':
|
||||
cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare(
|
||||
members?.find((m) => m.userId === b.uploadedBy)?.name ?? ''
|
||||
)
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members])
|
||||
|
||||
const rowCacheRef = useRef(
|
||||
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
|
||||
@@ -245,12 +300,6 @@ export function Files() {
|
||||
},
|
||||
created: timeCell(file.uploadedAt),
|
||||
owner: ownerCell(file.uploadedBy, members),
|
||||
updated: timeCell(file.uploadedAt),
|
||||
},
|
||||
sortValues: {
|
||||
size: file.size,
|
||||
created: -new Date(file.uploadedAt).getTime(),
|
||||
updated: -new Date(file.uploadedAt).getTime(),
|
||||
},
|
||||
}
|
||||
nextCache.set(file.id, { row, file, members })
|
||||
@@ -342,7 +391,7 @@ export function Files() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId]
|
||||
[workspaceId, uploadFile]
|
||||
)
|
||||
|
||||
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
|
||||
@@ -690,7 +739,6 @@ export function Files() {
|
||||
handleDeleteSelected,
|
||||
])
|
||||
|
||||
/** Stable refs for values used in callbacks to avoid dependency churn */
|
||||
const listRenameRef = useRef(listRename)
|
||||
listRenameRef.current = listRename
|
||||
const headerRenameRef = useRef(headerRename)
|
||||
@@ -711,18 +759,14 @@ export function Files() {
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const handleSearchClearAll = useCallback(() => {
|
||||
handleSearchChange('')
|
||||
}, [handleSearchChange])
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: inputValue,
|
||||
onChange: handleSearchChange,
|
||||
onClearAll: handleSearchClearAll,
|
||||
onChange: setInputValue,
|
||||
onClearAll: () => setInputValue(''),
|
||||
placeholder: 'Search files...',
|
||||
}),
|
||||
[inputValue, handleSearchChange, handleSearchClearAll]
|
||||
[inputValue]
|
||||
)
|
||||
|
||||
const createConfig = useMemo(
|
||||
@@ -764,6 +808,205 @@ export function Files() {
|
||||
[handleNavigateToFiles]
|
||||
)
|
||||
|
||||
const typeDisplayLabel = useMemo(() => {
|
||||
if (typeFilter.length === 0) return 'All'
|
||||
if (typeFilter.length === 1) {
|
||||
const labels: Record<string, string> = {
|
||||
document: 'Documents',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
}
|
||||
return labels[typeFilter[0]] ?? typeFilter[0]
|
||||
}
|
||||
return `${typeFilter.length} selected`
|
||||
}, [typeFilter])
|
||||
|
||||
const sizeDisplayLabel = useMemo(() => {
|
||||
if (sizeFilter.length === 0) return 'All'
|
||||
if (sizeFilter.length === 1) {
|
||||
const labels: Record<string, string> = { small: 'Small', medium: 'Medium', large: 'Large' }
|
||||
return labels[sizeFilter[0]] ?? sizeFilter[0]
|
||||
}
|
||||
return `${sizeFilter.length} selected`
|
||||
}, [sizeFilter])
|
||||
|
||||
const uploadedByDisplayLabel = useMemo(() => {
|
||||
if (uploadedByFilter.length === 0) return 'All'
|
||||
if (uploadedByFilter.length === 1)
|
||||
return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'
|
||||
return `${uploadedByFilter.length} members`
|
||||
}, [uploadedByFilter, members])
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
(members ?? []).map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.name,
|
||||
iconElement: m.image ? (
|
||||
<img
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
referrerPolicy='no-referrer'
|
||||
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[members]
|
||||
)
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'size', label: 'Size' },
|
||||
{ id: 'type', label: 'Type' },
|
||||
{ id: 'created', label: 'Created' },
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const hasActiveFilters =
|
||||
typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>File Type</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'document', label: 'Documents' },
|
||||
{ value: 'audio', label: 'Audio' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={typeFilter}
|
||||
onMultiSelectChange={setTypeFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Size</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'small', label: 'Small (< 1 MB)' },
|
||||
{ value: 'medium', label: 'Medium (1–10 MB)' },
|
||||
{ value: 'large', label: 'Large (> 10 MB)' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={sizeFilter}
|
||||
onMultiSelectChange={setSizeFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{memberOptions.length > 0 && (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Uploaded By
|
||||
</span>
|
||||
<Combobox
|
||||
options={memberOptions}
|
||||
multiSelect
|
||||
multiSelectValues={uploadedByFilter}
|
||||
onMultiSelectChange={setUploadedByFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{uploadedByDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search members...'
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setTypeFilter([])
|
||||
setSizeFilter([])
|
||||
setUploadedByFilter([])
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
typeFilter,
|
||||
sizeFilter,
|
||||
uploadedByFilter,
|
||||
memberOptions,
|
||||
typeDisplayLabel,
|
||||
sizeDisplayLabel,
|
||||
uploadedByDisplayLabel,
|
||||
hasActiveFilters,
|
||||
]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
if (typeFilter.length > 0) {
|
||||
const typeLabels: Record<string, string> = {
|
||||
document: 'Documents',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
}
|
||||
const label =
|
||||
typeFilter.length === 1
|
||||
? `Type: ${typeLabels[typeFilter[0]]}`
|
||||
: `Type: ${typeFilter.length} selected`
|
||||
tags.push({ label, onRemove: () => setTypeFilter([]) })
|
||||
}
|
||||
if (sizeFilter.length > 0) {
|
||||
const sizeLabels: Record<string, string> = {
|
||||
small: 'Small',
|
||||
medium: 'Medium',
|
||||
large: 'Large',
|
||||
}
|
||||
const label =
|
||||
sizeFilter.length === 1
|
||||
? `Size: ${sizeLabels[sizeFilter[0]]}`
|
||||
: `Size: ${sizeFilter.length} selected`
|
||||
tags.push({ label, onRemove: () => setSizeFilter([]) })
|
||||
}
|
||||
if (uploadedByFilter.length > 0) {
|
||||
const label =
|
||||
uploadedByFilter.length === 1
|
||||
? `Uploaded by: ${members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'}`
|
||||
: `Uploaded by: ${uploadedByFilter.length} members`
|
||||
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [typeFilter, sizeFilter, uploadedByFilter, members])
|
||||
|
||||
if (fileIdFromRoute && !selectedFile) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
@@ -834,7 +1077,9 @@ export function Files() {
|
||||
title='Files'
|
||||
create={createConfig}
|
||||
search={searchConfig}
|
||||
defaultSort='created'
|
||||
sort={sortConfig}
|
||||
filter={filterContent}
|
||||
filterTags={filterTags}
|
||||
headerActions={headerActionsConfig}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
|
||||
@@ -91,7 +91,9 @@ export function MothershipChat({
|
||||
}: MothershipChatProps) {
|
||||
const styles = LAYOUT_STYLES[layout]
|
||||
const isStreamActive = isSending || isReconnecting
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive)
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
|
||||
scrollOnMount: true,
|
||||
})
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
@@ -72,7 +72,16 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
const DocIcon = getDocumentIcon('', item.name)
|
||||
return (
|
||||
<>
|
||||
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
|
||||
return (
|
||||
<>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
@@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
|
||||
},
|
||||
file: {
|
||||
type: 'file',
|
||||
@@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<Database className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & {
|
||||
}
|
||||
|
||||
export interface PlusMenuHandle {
|
||||
open: () => void
|
||||
open: (anchor?: { left: number; top: number }) => void
|
||||
}
|
||||
|
||||
export const TEXTAREA_BASE_CLASSES = cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Plus, Sim } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
|
||||
@@ -37,24 +36,24 @@ export const PlusMenuDropdown = React.memo(
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeIndexRef.current = activeIndex
|
||||
}, [activeIndex])
|
||||
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
|
||||
if (anchor) {
|
||||
setAnchorPos(anchor)
|
||||
} else {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setAnchorPos({ left: rect.left, top: rect.top })
|
||||
}
|
||||
setOpen(true)
|
||||
setSearch('')
|
||||
}, [])
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open: () => {
|
||||
setOpen(true)
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = search.toLowerCase().trim()
|
||||
@@ -69,7 +68,6 @@ export const PlusMenuDropdown = React.memo(
|
||||
onResourceSelect(resource)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
},
|
||||
[onResourceSelect]
|
||||
)
|
||||
@@ -79,32 +77,37 @@ export const PlusMenuDropdown = React.memo(
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const items = filteredItemsRef.current
|
||||
if (!items) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0))
|
||||
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
|
||||
firstItem?.focus()
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const idx = activeIndexRef.current
|
||||
if (items.length > 0 && items[idx]) {
|
||||
const { type, item } = items[idx]
|
||||
handleSelect({ type, id: item.id, title: item.name })
|
||||
}
|
||||
const first = filteredItemsRef.current?.[0]
|
||||
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
|
||||
}
|
||||
},
|
||||
[handleSelect]
|
||||
)
|
||||
|
||||
const handleContentKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
|
||||
)
|
||||
if (items[0] && items[0] === document.activeElement) {
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
setSearch('')
|
||||
setActiveIndex(0)
|
||||
setAnchorPos(null)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
@@ -126,126 +129,138 @@ export const PlusMenuDropdown = React.memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchorPos?.left ?? 0,
|
||||
top: anchorPos?.top ?? 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
ref={contentRef}
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<DropdownMenuSearchInput
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
<DropdownMenuSearchInput
|
||||
ref={searchRef}
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type='button'
|
||||
onClick={() => doOpen()}
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -50,6 +50,50 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
function getCaretAnchor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
caretPos: number
|
||||
): { left: number; top: number } {
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textarea)
|
||||
|
||||
const mirror = document.createElement('div')
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.top = '0'
|
||||
mirror.style.left = '0'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.overflowWrap = 'break-word'
|
||||
mirror.style.font = style.font
|
||||
mirror.style.padding = style.padding
|
||||
mirror.style.border = style.border
|
||||
mirror.style.width = style.width
|
||||
mirror.style.lineHeight = style.lineHeight
|
||||
mirror.style.boxSizing = style.boxSizing
|
||||
mirror.style.letterSpacing = style.letterSpacing
|
||||
mirror.style.textTransform = style.textTransform
|
||||
mirror.style.textIndent = style.textIndent
|
||||
mirror.style.textAlign = style.textAlign
|
||||
mirror.textContent = textarea.value.substring(0, caretPos)
|
||||
|
||||
const marker = document.createElement('span')
|
||||
marker.style.display = 'inline-block'
|
||||
marker.style.width = '0px'
|
||||
marker.style.padding = '0'
|
||||
marker.style.border = '0'
|
||||
mirror.appendChild(marker)
|
||||
|
||||
document.body.appendChild(mirror)
|
||||
const markerRect = marker.getBoundingClientRect()
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
return {
|
||||
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
|
||||
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
|
||||
}
|
||||
}
|
||||
|
||||
interface UserInputProps {
|
||||
defaultValue?: string
|
||||
editValue?: string
|
||||
@@ -486,7 +530,8 @@ export function UserInput({
|
||||
const adjusted = `${before}${after}`
|
||||
setValue(adjusted)
|
||||
atInsertPosRef.current = caret - 1
|
||||
plusMenuRef.current?.open()
|
||||
const anchor = getCaretAnchor(e.target, caret - 1)
|
||||
plusMenuRef.current?.open(anchor)
|
||||
restartRecognition(adjusted)
|
||||
return
|
||||
}
|
||||
@@ -522,6 +567,28 @@ export function UserInput({
|
||||
[isInitialView]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const pastedFiles: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) pastedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (pastedFiles.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
const dt = new DataTransfer()
|
||||
for (const file of pastedFiles) {
|
||||
dt.items.add(file)
|
||||
}
|
||||
filesRef.current.processFiles(dt.files)
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = e.currentTarget.scrollTop
|
||||
@@ -661,6 +728,7 @@ export function UserInput({
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCut={mentionTokensWithContext.handleCut}
|
||||
onSelect={handleSelectAdjust}
|
||||
onMouseUp={handleSelectAdjust}
|
||||
|
||||
@@ -1213,30 +1213,31 @@ export function useChat(
|
||||
}
|
||||
flush()
|
||||
|
||||
if (shouldOpenGenericResource(name)) {
|
||||
if (!genericEntryMap.has(id)) {
|
||||
const entryIdx = appendGenericEntry({
|
||||
toolCallId: id,
|
||||
toolName: name,
|
||||
displayTitle: displayTitle ?? name,
|
||||
status: 'executing',
|
||||
params: args,
|
||||
})
|
||||
genericEntryMap.set(id, entryIdx)
|
||||
const opened = addResource({ type: 'generic', id: 'results', title: 'Results' })
|
||||
if (opened) onResourceEventRef.current?.()
|
||||
else setActiveResourceId('results')
|
||||
} else {
|
||||
const entryIdx = genericEntryMap.get(id)
|
||||
if (entryIdx !== undefined) {
|
||||
updateGenericEntry(entryIdx, {
|
||||
toolName: name,
|
||||
...(displayTitle && { displayTitle }),
|
||||
...(args && { params: args }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Uncomment when rich UI for Results tab is ready
|
||||
// if (shouldOpenGenericResource(name)) {
|
||||
// if (!genericEntryMap.has(id)) {
|
||||
// const entryIdx = appendGenericEntry({
|
||||
// toolCallId: id,
|
||||
// toolName: name,
|
||||
// displayTitle: displayTitle ?? name,
|
||||
// status: 'executing',
|
||||
// params: args,
|
||||
// })
|
||||
// genericEntryMap.set(id, entryIdx)
|
||||
// const opened = addResource({ type: 'generic', id: 'results', title: 'Results' })
|
||||
// if (opened) onResourceEventRef.current?.()
|
||||
// else setActiveResourceId('results')
|
||||
// } else {
|
||||
// const entryIdx = genericEntryMap.get(id)
|
||||
// if (entryIdx !== undefined) {
|
||||
// updateGenericEntry(entryIdx, {
|
||||
// toolName: name,
|
||||
// ...(displayTitle && { displayTitle }),
|
||||
// ...(args && { params: args }),
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if (
|
||||
parsed.type === 'tool_call' &&
|
||||
@@ -1333,17 +1334,18 @@ export function useChat(
|
||||
flush()
|
||||
}
|
||||
|
||||
if (toolName && shouldOpenGenericResource(toolName)) {
|
||||
const entryIdx = genericEntryMap.get(id)
|
||||
if (entryIdx !== undefined) {
|
||||
const entry = genericResourceDataRef.current.entries[entryIdx]
|
||||
if (entry) {
|
||||
updateGenericEntry(entryIdx, {
|
||||
streamingArgs: (entry.streamingArgs ?? '') + delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Uncomment when rich UI for Results tab is ready
|
||||
// if (toolName && shouldOpenGenericResource(toolName)) {
|
||||
// const entryIdx = genericEntryMap.get(id)
|
||||
// if (entryIdx !== undefined) {
|
||||
// const entry = genericResourceDataRef.current.entries[entryIdx]
|
||||
// if (entry) {
|
||||
// updateGenericEntry(entryIdx, {
|
||||
// streamingArgs: (entry.streamingArgs ?? '') + delta,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
break
|
||||
}
|
||||
@@ -1452,32 +1454,33 @@ export function useChat(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
shouldOpenGenericResource(tc.name) ||
|
||||
(isDeferredResourceTool(tc.name) && extractedResources.length === 0)
|
||||
) {
|
||||
const entryIdx = genericEntryMap.get(id)
|
||||
if (entryIdx !== undefined) {
|
||||
updateGenericEntry(entryIdx, {
|
||||
status: tc.status,
|
||||
result: tc.result ?? undefined,
|
||||
streamingArgs: undefined,
|
||||
})
|
||||
} else {
|
||||
const newIdx = appendGenericEntry({
|
||||
toolCallId: id,
|
||||
toolName: tc.name,
|
||||
displayTitle: tc.displayTitle ?? tc.name,
|
||||
status: tc.status,
|
||||
params: toolArgsMap.get(id) as Record<string, unknown> | undefined,
|
||||
result: tc.result ?? undefined,
|
||||
})
|
||||
genericEntryMap.set(id, newIdx)
|
||||
if (addResource({ type: 'generic', id: 'results', title: 'Results' })) {
|
||||
onResourceEventRef.current?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Uncomment when rich UI for Results tab is ready
|
||||
// if (
|
||||
// shouldOpenGenericResource(tc.name) ||
|
||||
// (isDeferredResourceTool(tc.name) && extractedResources.length === 0)
|
||||
// ) {
|
||||
// const entryIdx = genericEntryMap.get(id)
|
||||
// if (entryIdx !== undefined) {
|
||||
// updateGenericEntry(entryIdx, {
|
||||
// status: tc.status,
|
||||
// result: tc.result ?? undefined,
|
||||
// streamingArgs: undefined,
|
||||
// })
|
||||
// } else {
|
||||
// const newIdx = appendGenericEntry({
|
||||
// toolCallId: id,
|
||||
// toolName: tc.name,
|
||||
// displayTitle: tc.displayTitle ?? tc.name,
|
||||
// status: tc.status,
|
||||
// params: toolArgsMap.get(id) as Record<string, unknown> | undefined,
|
||||
// result: tc.result ?? undefined,
|
||||
// })
|
||||
// genericEntryMap.set(id, newIdx)
|
||||
// if (addResource({ type: 'generic', id: 'results', title: 'Results' })) {
|
||||
// onResourceEventRef.current?.()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
break
|
||||
@@ -1574,12 +1577,13 @@ export function useChat(
|
||||
}
|
||||
flush()
|
||||
|
||||
if (toolCallName && shouldOpenGenericResource(toolCallName)) {
|
||||
const entryIdx = genericEntryMap.get(id)
|
||||
if (entryIdx !== undefined) {
|
||||
updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
|
||||
}
|
||||
}
|
||||
// TODO: Uncomment when rich UI for Results tab is ready
|
||||
// if (toolCallName && shouldOpenGenericResource(toolCallName)) {
|
||||
// const entryIdx = genericEntryMap.get(id)
|
||||
// if (entryIdx !== undefined) {
|
||||
// updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Combobox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ChunkData } from '@/lib/knowledge/types'
|
||||
import { formatTokenCount } from '@/lib/tokenization'
|
||||
import type {
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SelectableConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource, ResourceHeader } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
@@ -152,7 +153,16 @@ export function Document({
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
|
||||
const enabledFilterParam = useMemo(
|
||||
() => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'),
|
||||
[enabledFilter]
|
||||
)
|
||||
|
||||
const {
|
||||
chunks: initialChunks,
|
||||
@@ -165,7 +175,21 @@ export function Document({
|
||||
refreshChunks: initialRefreshChunks,
|
||||
updateChunk: initialUpdateChunk,
|
||||
isFetching: isFetchingChunks,
|
||||
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL, '', enabledFilter)
|
||||
} = useDocumentChunks(
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
currentPageFromURL,
|
||||
'',
|
||||
enabledFilterParam,
|
||||
activeSort?.column === 'tokens'
|
||||
? 'tokenCount'
|
||||
: activeSort?.column === 'status'
|
||||
? 'enabled'
|
||||
: activeSort?.column === 'index'
|
||||
? 'chunkIndex'
|
||||
: undefined,
|
||||
activeSort?.direction
|
||||
)
|
||||
|
||||
const { data: searchResults = [], error: searchQueryError } = useDocumentChunkSearchQuery(
|
||||
{
|
||||
@@ -229,7 +253,10 @@ export function Document({
|
||||
searchStartIndex + SEARCH_PAGE_SIZE
|
||||
)
|
||||
|
||||
const displayChunks = showingSearch ? paginatedSearchResults : initialChunks
|
||||
const rawDisplayChunks = showingSearch ? paginatedSearchResults : initialChunks
|
||||
|
||||
const displayChunks = rawDisplayChunks ?? []
|
||||
|
||||
const currentPage = showingSearch ? searchCurrentPage : initialPage
|
||||
const totalPages = showingSearch ? searchTotalPages : initialTotalPages
|
||||
const hasNextPage = showingSearch ? searchCurrentPage < searchTotalPages : initialHasNextPage
|
||||
@@ -562,47 +589,68 @@ export function Document({
|
||||
}
|
||||
: undefined
|
||||
|
||||
const filterContent = (
|
||||
<div className='w-[200px]'>
|
||||
<div className='border-[var(--border-1)] border-b px-3 py-2'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 px-3 py-2'>
|
||||
{(['all', 'enabled', 'disabled'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
|
||||
enabledFilter === value && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setEnabledFilter(value)
|
||||
const enabledDisplayLabel = useMemo(() => {
|
||||
if (enabledFilter.length === 0) return 'All'
|
||||
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
|
||||
return `${enabledFilter.length} selected`
|
||||
}, [enabledFilter])
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'enabled', label: 'Enabled' },
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={enabledFilter}
|
||||
onMultiSelectChange={(values) => {
|
||||
setEnabledFilter(values)
|
||||
setSelectedChunks(new Set())
|
||||
void goToPage(1)
|
||||
}}
|
||||
>
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = [
|
||||
...(enabledFilter !== 'all'
|
||||
? [
|
||||
{
|
||||
label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`,
|
||||
onRemove: () => {
|
||||
setEnabledFilter('all')
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{enabledFilter.length > 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setEnabledFilter([])
|
||||
setSelectedChunks(new Set())
|
||||
void goToPage(1)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[enabledFilter, enabledDisplayLabel, goToPage]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(
|
||||
() =>
|
||||
enabledFilter.map((value) => ({
|
||||
label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`,
|
||||
onRemove: () => {
|
||||
setEnabledFilter((prev) => prev.filter((v) => v !== value))
|
||||
setSelectedChunks(new Set())
|
||||
void goToPage(1)
|
||||
},
|
||||
})),
|
||||
[enabledFilter, goToPage]
|
||||
)
|
||||
|
||||
const handleChunkClick = useCallback((rowId: string) => {
|
||||
setSelectedChunkId(rowId)
|
||||
@@ -814,6 +862,26 @@ export function Document({
|
||||
}
|
||||
: undefined
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'index', label: 'Index' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
setActiveSort({ column, direction })
|
||||
void goToPage(1)
|
||||
},
|
||||
onClear: () => {
|
||||
setActiveSort(null)
|
||||
void goToPage(1)
|
||||
},
|
||||
}),
|
||||
[activeSort, goToPage]
|
||||
)
|
||||
|
||||
const chunkRows: ResourceRow[] = useMemo(() => {
|
||||
if (!isCompleted) {
|
||||
return [
|
||||
@@ -1100,6 +1168,7 @@ export function Document({
|
||||
emptyMessage={emptyMessage}
|
||||
filter={combinedError ? undefined : filterContent}
|
||||
filterTags={combinedError ? undefined : filterTags}
|
||||
sort={combinedError ? undefined : sortConfig}
|
||||
/>
|
||||
|
||||
<DocumentTagsModal
|
||||
|
||||
@@ -208,7 +208,7 @@ export function KnowledgeBase({
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
|
||||
const [tagFilterEntries, setTagFilterEntries] = useState<
|
||||
{
|
||||
id: string
|
||||
@@ -235,6 +235,17 @@ export function KnowledgeBase({
|
||||
[tagFilterEntries]
|
||||
)
|
||||
|
||||
const enabledFilterParam = useMemo<'all' | 'enabled' | 'disabled'>(() => {
|
||||
if (enabledFilter.length === 1) return enabledFilter[0] as 'enabled' | 'disabled'
|
||||
return 'all'
|
||||
}, [enabledFilter])
|
||||
|
||||
const enabledDisplayLabel = useMemo(() => {
|
||||
if (enabledFilter.length === 0) return 'All'
|
||||
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
|
||||
return '2 selected'
|
||||
}, [enabledFilter])
|
||||
|
||||
const handleSearchChange = useCallback((newQuery: string) => {
|
||||
setSearchQuery(newQuery)
|
||||
setCurrentPage(1)
|
||||
@@ -249,8 +260,10 @@ export function KnowledgeBase({
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
const [showConnectorsModal, setShowConnectorsModal] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
@@ -290,8 +303,8 @@ export function KnowledgeBase({
|
||||
search: searchQuery || undefined,
|
||||
limit: DOCUMENTS_PER_PAGE,
|
||||
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
sortBy: (activeSort?.column ?? 'uploadedAt') as DocumentSortField,
|
||||
sortOrder: (activeSort?.direction ?? 'desc') as SortOrder,
|
||||
refetchInterval: (data) => {
|
||||
if (isDeleting) return false
|
||||
const hasPending = data?.documents?.some(
|
||||
@@ -301,7 +314,7 @@ export function KnowledgeBase({
|
||||
if (hasSyncingConnectorsRef.current) return 5000
|
||||
return false
|
||||
},
|
||||
enabledFilter,
|
||||
enabledFilter: enabledFilterParam,
|
||||
tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined,
|
||||
})
|
||||
|
||||
@@ -571,7 +584,7 @@ export function KnowledgeBase({
|
||||
knowledgeBaseId: id,
|
||||
operation: 'enable',
|
||||
selectAll: true,
|
||||
enabledFilter,
|
||||
enabledFilter: enabledFilterParam,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
@@ -618,7 +631,7 @@ export function KnowledgeBase({
|
||||
knowledgeBaseId: id,
|
||||
operation: 'disable',
|
||||
selectAll: true,
|
||||
enabledFilter,
|
||||
enabledFilter: enabledFilterParam,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
@@ -667,7 +680,7 @@ export function KnowledgeBase({
|
||||
knowledgeBaseId: id,
|
||||
operation: 'delete',
|
||||
selectAll: true,
|
||||
enabledFilter,
|
||||
enabledFilter: enabledFilterParam,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
@@ -707,12 +720,12 @@ export function KnowledgeBase({
|
||||
|
||||
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||
const enabledCount = isSelectAllMode
|
||||
? enabledFilter === 'disabled'
|
||||
? enabledFilterParam === 'disabled'
|
||||
? 0
|
||||
: pagination.total
|
||||
: selectedDocumentsList.filter((doc) => doc.enabled).length
|
||||
const disabledCount = isSelectAllMode
|
||||
? enabledFilter === 'enabled'
|
||||
? enabledFilterParam === 'enabled'
|
||||
? 0
|
||||
: pagination.total
|
||||
: selectedDocumentsList.filter((doc) => !doc.enabled).length
|
||||
@@ -795,59 +808,83 @@ export function KnowledgeBase({
|
||||
: []),
|
||||
]
|
||||
|
||||
const sortConfig: SortConfig = {
|
||||
options: [
|
||||
{ id: 'filename', label: 'Name' },
|
||||
{ id: 'fileSize', label: 'Size' },
|
||||
{ id: 'tokenCount', label: 'Tokens' },
|
||||
{ id: 'chunkCount', label: 'Chunks' },
|
||||
{ id: 'uploadedAt', label: 'Uploaded' },
|
||||
{ id: 'enabled', label: 'Status' },
|
||||
],
|
||||
active: { column: sortBy, direction: sortOrder },
|
||||
onSort: (column, direction) => {
|
||||
setSortBy(column as DocumentSortField)
|
||||
setSortOrder(direction)
|
||||
setCurrentPage(1)
|
||||
},
|
||||
}
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'filename', label: 'Name' },
|
||||
{ id: 'fileSize', label: 'Size' },
|
||||
{ id: 'tokenCount', label: 'Tokens' },
|
||||
{ id: 'chunkCount', label: 'Chunks' },
|
||||
{ id: 'uploadedAt', label: 'Uploaded' },
|
||||
{ id: 'enabled', label: 'Status' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
setActiveSort({ column, direction })
|
||||
setCurrentPage(1)
|
||||
},
|
||||
onClear: () => {
|
||||
setActiveSort(null)
|
||||
setCurrentPage(1)
|
||||
},
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const filterContent = (
|
||||
<div className='w-[320px]'>
|
||||
<div className='border-[var(--border-1)] border-b px-3 py-2'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 px-3 py-2'>
|
||||
{(['all', 'enabled', 'disabled'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
|
||||
enabledFilter === value && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setEnabledFilter(value)
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'enabled', label: 'Enabled' },
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={enabledFilter}
|
||||
onMultiSelectChange={(values) => {
|
||||
setEnabledFilter(values)
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
}}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{enabledFilter.length > 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setEnabledFilter([])
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
Clear status filter
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
<TagFilterSection
|
||||
tagDefinitions={tagDefinitions}
|
||||
entries={tagFilterEntries}
|
||||
onChange={(entries) => {
|
||||
setTagFilterEntries(entries)
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TagFilterSection
|
||||
tagDefinitions={tagDefinitions}
|
||||
entries={tagFilterEntries}
|
||||
onChange={(entries) => {
|
||||
setTagFilterEntries(entries)
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[enabledFilter, enabledDisplayLabel, tagDefinitions, tagFilterEntries]
|
||||
)
|
||||
|
||||
const connectorBadges =
|
||||
@@ -871,33 +908,39 @@ export function KnowledgeBase({
|
||||
</>
|
||||
) : null
|
||||
|
||||
const filterTags: FilterTag[] = [
|
||||
...(enabledFilter !== 'all'
|
||||
? [
|
||||
{
|
||||
label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`,
|
||||
onRemove: () => {
|
||||
setEnabledFilter('all')
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
const filterTags: FilterTag[] = useMemo(
|
||||
() => [
|
||||
...(enabledFilter.length > 0
|
||||
? [
|
||||
{
|
||||
label:
|
||||
enabledFilter.length === 1
|
||||
? `Status: ${enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'}`
|
||||
: 'Status: 2 selected',
|
||||
onRemove: () => {
|
||||
setEnabledFilter([])
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...tagFilterEntries
|
||||
.filter((f) => f.tagSlot && f.value.trim())
|
||||
.map((f) => ({
|
||||
label: `${f.tagName}: ${f.value}`,
|
||||
onRemove: () => {
|
||||
const updated = tagFilterEntries.filter((e) => e.id !== f.id)
|
||||
setTagFilterEntries(updated)
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...tagFilterEntries
|
||||
.filter((f) => f.tagSlot && f.value.trim())
|
||||
.map((f) => ({
|
||||
label: `${f.tagName}: ${f.value}`,
|
||||
onRemove: () => {
|
||||
const updated = tagFilterEntries.filter((_, idx) => idx !== tagFilterEntries.indexOf(f))
|
||||
setTagFilterEntries(updated)
|
||||
setCurrentPage(1)
|
||||
setSelectedDocuments(new Set())
|
||||
setIsSelectAllMode(false)
|
||||
},
|
||||
})),
|
||||
]
|
||||
})),
|
||||
],
|
||||
[enabledFilter, tagFilterEntries]
|
||||
)
|
||||
|
||||
const selectableConfig: SelectableConfig = {
|
||||
selectedIds: selectedDocuments,
|
||||
@@ -922,7 +965,7 @@ export function KnowledgeBase({
|
||||
content: (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div style={{ cursor: 'help' }}>{getStatusBadge(doc)}</div>
|
||||
<div className='cursor-help'>{getStatusBadge(doc)}</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-xs'>
|
||||
{doc.processingError}
|
||||
@@ -1019,7 +1062,7 @@ export function KnowledgeBase({
|
||||
|
||||
const emptyMessage = searchQuery
|
||||
? 'No documents found'
|
||||
: enabledFilter !== 'all' || activeTagFilters.length > 0
|
||||
: enabledFilter.length > 0 || activeTagFilters.length > 0
|
||||
? 'Nothing matches your filter'
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* Auth: API key input or OAuth credential selection */}
|
||||
{isApiKeyMode ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>
|
||||
{connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
|
||||
? connectorConfig.auth.label
|
||||
@@ -394,7 +394,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Account</Label>
|
||||
{credentialsLoading ? (
|
||||
<div className='flex items-center gap-2 text-[var(--text-muted)] text-small'>
|
||||
@@ -442,7 +442,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
|
||||
|
||||
return (
|
||||
<div key={field.id} className='flex flex-col gap-1'>
|
||||
<div key={field.id} className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>
|
||||
{field.title}
|
||||
@@ -507,7 +507,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
|
||||
{/* Tag definitions (opt-out) */}
|
||||
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Metadata Tags</Label>
|
||||
{connectorConfig.tagDefinitions.map((tagDef) => (
|
||||
<div
|
||||
@@ -550,7 +550,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
)}
|
||||
|
||||
{/* Sync interval */}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Sync Frequency</Label>
|
||||
<ButtonGroup
|
||||
value={String(syncInterval)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -77,6 +78,12 @@ export function ConnectorsSection({
|
||||
const { mutate: updateConnector } = useUpdateConnector()
|
||||
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [deleteDocuments, setDeleteDocuments] = useState(false)
|
||||
|
||||
const closeDeleteModal = useCallback(() => {
|
||||
setDeleteTarget(null)
|
||||
setDeleteDocuments(false)
|
||||
}, [])
|
||||
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
|
||||
@@ -224,22 +231,30 @@ export function ConnectorsSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<Modal open={deleteTarget !== null} onOpenChange={closeDeleteModal}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Connector</ModalHeader>
|
||||
<ModalHeader>Remove Connector</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
Are you sure you want to remove this connected source?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will stop future syncs from this source.
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
Documents already synced will remain in the knowledge base.
|
||||
</span>
|
||||
This will disconnect the source and stop future syncs. Documents already synced will
|
||||
remain in the knowledge base unless you choose to delete them.
|
||||
</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='delete-docs'
|
||||
checked={deleteDocuments}
|
||||
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor='delete-docs'
|
||||
className='cursor-pointer text-[var(--text-secondary)] text-sm'
|
||||
>
|
||||
Also delete all synced documents
|
||||
</label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setDeleteTarget(null)} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={closeDeleteModal} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -248,23 +263,23 @@ export function ConnectorsSection({
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
deleteConnector(
|
||||
{ knowledgeBaseId, connectorId: deleteTarget },
|
||||
{ knowledgeBaseId, connectorId: deleteTarget, deleteDocuments },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setError(null)
|
||||
setDeleteTarget(null)
|
||||
closeDeleteModal()
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Delete connector failed', { error: err.message })
|
||||
setError(err.message)
|
||||
setDeleteTarget(null)
|
||||
closeDeleteModal()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
{isDeleting ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -198,7 +198,7 @@ function SettingsTab({
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{connectorConfig?.configFields.map((field) => (
|
||||
<div key={field.id} className='flex flex-col gap-1'>
|
||||
<div key={field.id} className='flex flex-col gap-2'>
|
||||
<Label>
|
||||
{field.title}
|
||||
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
|
||||
@@ -227,7 +227,7 @@ function SettingsTab({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Sync Frequency</Label>
|
||||
<ButtonGroup
|
||||
value={String(syncInterval)}
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import type { ComboboxOption } from '@/components/emcn'
|
||||
import { Combobox, Tooltip } from '@/components/emcn'
|
||||
import { Database } from '@/components/emcn/icons'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import type {
|
||||
CreateAction,
|
||||
FilterTag,
|
||||
ResourceCell,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
@@ -29,6 +32,7 @@ import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
@@ -98,21 +102,16 @@ export function Knowledge() {
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
|
||||
const [contentFilter, setContentFilter] = useState<string[]>([])
|
||||
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
|
||||
|
||||
const [searchInputValue, setSearchInputValue] = useState('')
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInputValue(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchQuery(value)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const handleSearchClearAll = useCallback(() => {
|
||||
handleSearchChange('')
|
||||
}, [handleSearchChange])
|
||||
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
|
||||
@@ -184,14 +183,77 @@ export function Knowledge() {
|
||||
[deleteKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
const filteredKnowledgeBases = useMemo(
|
||||
() => filterKnowledgeBases(knowledgeBases, debouncedSearchQuery),
|
||||
[knowledgeBases, debouncedSearchQuery]
|
||||
)
|
||||
const processedKBs = useMemo(() => {
|
||||
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||
|
||||
if (connectorFilter.length > 0) {
|
||||
result = result.filter((kb) => {
|
||||
const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0
|
||||
if (connectorFilter.includes('connected') && hasConnectors) return true
|
||||
if (connectorFilter.includes('unconnected') && !hasConnectors) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (contentFilter.length > 0) {
|
||||
const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0
|
||||
result = result.filter((kb) => {
|
||||
if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true
|
||||
if (contentFilter.includes('empty') && docCount(kb) === 0) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (ownerFilter.length > 0) {
|
||||
result = result.filter((kb) => ownerFilter.includes(kb.userId))
|
||||
}
|
||||
|
||||
const col = activeSort?.column ?? 'created'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'documents':
|
||||
cmp =
|
||||
((a as KnowledgeBaseWithDocCount).docCount || 0) -
|
||||
((b as KnowledgeBaseWithDocCount).docCount || 0)
|
||||
break
|
||||
case 'tokens':
|
||||
cmp = (a.tokenCount || 0) - (b.tokenCount || 0)
|
||||
break
|
||||
case 'created':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'updated':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
break
|
||||
case 'connectors':
|
||||
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
|
||||
break
|
||||
case 'owner':
|
||||
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
|
||||
members?.find((m) => m.userId === b.userId)?.name ?? ''
|
||||
)
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [
|
||||
knowledgeBases,
|
||||
debouncedSearchQuery,
|
||||
connectorFilter,
|
||||
contentFilter,
|
||||
ownerFilter,
|
||||
activeSort,
|
||||
members,
|
||||
])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredKnowledgeBases.map((kb) => {
|
||||
processedKBs.map((kb) => {
|
||||
const kbWithCount = kb as KnowledgeBaseWithDocCount
|
||||
return {
|
||||
id: kb.id,
|
||||
@@ -211,16 +273,9 @@ export function Knowledge() {
|
||||
owner: ownerCell(kb.userId, members),
|
||||
updated: timeCell(kb.updatedAt),
|
||||
},
|
||||
sortValues: {
|
||||
documents: kbWithCount.docCount || 0,
|
||||
tokens: kb.tokenCount || 0,
|
||||
connectors: kb.connectorTypes?.length || 0,
|
||||
created: -new Date(kb.createdAt).getTime(),
|
||||
updated: -new Date(kb.updatedAt).getTime(),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[filteredKnowledgeBases, members]
|
||||
[processedKBs, members]
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
@@ -303,13 +358,190 @@ export function Knowledge() {
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: searchInputValue,
|
||||
onChange: handleSearchChange,
|
||||
onClearAll: handleSearchClearAll,
|
||||
onChange: setSearchInputValue,
|
||||
onClearAll: () => setSearchInputValue(''),
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}),
|
||||
[searchInputValue, handleSearchChange, handleSearchClearAll]
|
||||
[searchInputValue]
|
||||
)
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'documents', label: 'Documents' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'connectors', label: 'Connectors' },
|
||||
{ id: 'created', label: 'Created' },
|
||||
{ id: 'updated', label: 'Last Updated' },
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const connectorDisplayLabel = useMemo(() => {
|
||||
if (connectorFilter.length === 0) return 'All'
|
||||
if (connectorFilter.length === 1)
|
||||
return connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'
|
||||
return `${connectorFilter.length} selected`
|
||||
}, [connectorFilter])
|
||||
|
||||
const contentDisplayLabel = useMemo(() => {
|
||||
if (contentFilter.length === 0) return 'All'
|
||||
if (contentFilter.length === 1)
|
||||
return contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'
|
||||
return `${contentFilter.length} selected`
|
||||
}, [contentFilter])
|
||||
|
||||
const ownerDisplayLabel = useMemo(() => {
|
||||
if (ownerFilter.length === 0) return 'All'
|
||||
if (ownerFilter.length === 1)
|
||||
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
|
||||
return `${ownerFilter.length} members`
|
||||
}, [ownerFilter, members])
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
(members ?? []).map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.name,
|
||||
iconElement: m.image ? (
|
||||
<img
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
referrerPolicy='no-referrer'
|
||||
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[members]
|
||||
)
|
||||
|
||||
const hasActiveFilters =
|
||||
connectorFilter.length > 0 || contentFilter.length > 0 || ownerFilter.length > 0
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Connectors</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'connected', label: 'With connectors' },
|
||||
{ value: 'unconnected', label: 'Without connectors' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={connectorFilter}
|
||||
onMultiSelectChange={setConnectorFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{connectorDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Content</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'has-docs', label: 'Has documents' },
|
||||
{ value: 'empty', label: 'Empty' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={contentFilter}
|
||||
onMultiSelectChange={setContentFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{contentDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{memberOptions.length > 0 && (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
|
||||
<Combobox
|
||||
options={memberOptions}
|
||||
multiSelect
|
||||
multiSelectValues={ownerFilter}
|
||||
onMultiSelectChange={setOwnerFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search members...'
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setConnectorFilter([])
|
||||
setContentFilter([])
|
||||
setOwnerFilter([])
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
connectorFilter,
|
||||
contentFilter,
|
||||
ownerFilter,
|
||||
memberOptions,
|
||||
connectorDisplayLabel,
|
||||
contentDisplayLabel,
|
||||
ownerDisplayLabel,
|
||||
hasActiveFilters,
|
||||
]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
if (connectorFilter.length > 0) {
|
||||
const label =
|
||||
connectorFilter.length === 1
|
||||
? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}`
|
||||
: `Connectors: ${connectorFilter.length} types`
|
||||
tags.push({ label, onRemove: () => setConnectorFilter([]) })
|
||||
}
|
||||
if (contentFilter.length > 0) {
|
||||
const label =
|
||||
contentFilter.length === 1
|
||||
? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}`
|
||||
: `Content: ${contentFilter.length} types`
|
||||
tags.push({ label, onRemove: () => setContentFilter([]) })
|
||||
}
|
||||
if (ownerFilter.length > 0) {
|
||||
const label =
|
||||
ownerFilter.length === 1
|
||||
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
|
||||
: `Owner: ${ownerFilter.length} members`
|
||||
tags.push({ label, onRemove: () => setOwnerFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [connectorFilter, contentFilter, ownerFilter, members])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
@@ -317,7 +549,9 @@ export function Knowledge() {
|
||||
title='Knowledge Base'
|
||||
create={createAction}
|
||||
search={searchConfig}
|
||||
defaultSort='created'
|
||||
sort={sortConfig}
|
||||
filter={filterContent}
|
||||
filterTags={filterTags}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
ResourceHeader,
|
||||
@@ -288,6 +289,10 @@ export default function Logs() {
|
||||
const activeLogRefetchRef = useRef<() => void>(() => {})
|
||||
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
|
||||
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
@@ -358,11 +363,43 @@ export default function Logs() {
|
||||
return logsQuery.data.pages.flatMap((page) => page.logs)
|
||||
}, [logsQuery.data?.pages])
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
if (!activeSort) return logs
|
||||
|
||||
const { column, direction } = activeSort
|
||||
return [...logs].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (column) {
|
||||
case 'date':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'duration': {
|
||||
const aDuration = parseDuration({ duration: a.duration ?? undefined }) ?? -1
|
||||
const bDuration = parseDuration({ duration: b.duration ?? undefined }) ?? -1
|
||||
cmp = aDuration - bDuration
|
||||
break
|
||||
}
|
||||
case 'cost': {
|
||||
const aCost = typeof a.cost?.total === 'number' ? a.cost.total : -1
|
||||
const bCost = typeof b.cost?.total === 'number' ? b.cost.total : -1
|
||||
cmp = aCost - bCost
|
||||
break
|
||||
}
|
||||
case 'status':
|
||||
cmp = (a.status ?? '').localeCompare(b.status ?? '')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [logs, activeSort])
|
||||
|
||||
const selectedLogIndex = useMemo(
|
||||
() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
|
||||
[logs, selectedLogId]
|
||||
() => (selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1),
|
||||
[sortedLogs, selectedLogId]
|
||||
)
|
||||
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
|
||||
const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogFromList) return null
|
||||
@@ -381,8 +418,8 @@ export default function Logs() {
|
||||
useFolders(workspaceId)
|
||||
|
||||
useEffect(() => {
|
||||
logsRef.current = logs
|
||||
}, [logs])
|
||||
logsRef.current = sortedLogs
|
||||
}, [sortedLogs])
|
||||
useEffect(() => {
|
||||
selectedLogIndexRef.current = selectedLogIndex
|
||||
}, [selectedLogIndex])
|
||||
@@ -443,12 +480,12 @@ export default function Logs() {
|
||||
const handleLogContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
e.preventDefault()
|
||||
const log = logs.find((l) => l.id === rowId) ?? null
|
||||
const log = sortedLogs.find((l) => l.id === rowId) ?? null
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuLog(log)
|
||||
setContextMenuOpen(true)
|
||||
},
|
||||
[logs]
|
||||
[sortedLogs]
|
||||
)
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
@@ -603,11 +640,12 @@ export default function Logs() {
|
||||
}, [initializeFromURL])
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (activeSort) return
|
||||
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
|
||||
if (!isFetching && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [])
|
||||
}, [activeSort])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -659,7 +697,7 @@ export default function Logs() {
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
logs.map((log) => {
|
||||
sortedLogs.map((log) => {
|
||||
const formattedDate = formatDate(log.createdAt)
|
||||
const displayStatus = getDisplayStatus(log.status)
|
||||
const isMothershipJob = log.trigger === 'mothership'
|
||||
@@ -710,7 +748,7 @@ export default function Logs() {
|
||||
},
|
||||
}
|
||||
}),
|
||||
[logs]
|
||||
[sortedLogs]
|
||||
)
|
||||
|
||||
const sidebarOverlay = useMemo(
|
||||
@@ -721,7 +759,7 @@ export default function Logs() {
|
||||
onClose={handleCloseSidebar}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
hasNext={selectedLogIndex < logs.length - 1}
|
||||
hasNext={selectedLogIndex < sortedLogs.length - 1}
|
||||
hasPrev={selectedLogIndex > 0}
|
||||
/>
|
||||
),
|
||||
@@ -732,7 +770,7 @@ export default function Logs() {
|
||||
handleNavigateNext,
|
||||
handleNavigatePrev,
|
||||
selectedLogIndex,
|
||||
logs.length,
|
||||
sortedLogs.length,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -978,6 +1016,21 @@ export default function Logs() {
|
||||
[appliedFilters, textSearch, removeBadge, handleFiltersChange]
|
||||
)
|
||||
|
||||
const sortConfig = useMemo<SortConfig>(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'date', label: 'Date' },
|
||||
{ id: 'duration', label: 'Duration' },
|
||||
{ id: 'cost', label: 'Cost' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const searchConfig = useMemo<SearchConfig>(
|
||||
() => ({
|
||||
value: currentInput,
|
||||
@@ -1021,7 +1074,7 @@ export default function Logs() {
|
||||
label: 'Export',
|
||||
icon: Download,
|
||||
onClick: handleExport,
|
||||
disabled: !userPermissions.canEdit || isExporting || logs.length === 0,
|
||||
disabled: !userPermissions.canEdit || isExporting || sortedLogs.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
@@ -1054,7 +1107,7 @@ export default function Logs() {
|
||||
handleExport,
|
||||
userPermissions.canEdit,
|
||||
isExporting,
|
||||
logs.length,
|
||||
sortedLogs.length,
|
||||
handleOpenNotificationSettings,
|
||||
]
|
||||
)
|
||||
@@ -1065,6 +1118,7 @@ export default function Logs() {
|
||||
<ResourceHeader icon={Library} title='Logs' actions={headerActions} />
|
||||
<ResourceOptionsBar
|
||||
search={searchConfig}
|
||||
sort={sortConfig}
|
||||
filter={
|
||||
<LogsFilterPanel searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} />
|
||||
}
|
||||
@@ -1091,7 +1145,7 @@ export default function Logs() {
|
||||
onRowContextMenu={handleLogContextMenu}
|
||||
isLoading={!logsQuery.data}
|
||||
onLoadMore={loadMoreLogs}
|
||||
hasMore={logsQuery.hasNextPage ?? false}
|
||||
hasMore={!activeSort && (logsQuery.hasNextPage ?? false)}
|
||||
isLoadingMore={logsQuery.isFetchingNextPage}
|
||||
emptyMessage='No logs found'
|
||||
overlay={sidebarOverlay}
|
||||
@@ -1335,7 +1389,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
}, [resetFilters, onSearchQueryChange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3 p-3'>
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
<Combobox
|
||||
|
||||
@@ -57,14 +57,26 @@ function parseShortcut(shortcut: string): ParsedShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
|
||||
* Needed for international keyboard layouts where e.key may produce unexpected characters
|
||||
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
|
||||
*/
|
||||
function codeToKey(code: string): string | undefined {
|
||||
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
|
||||
if (code.startsWith('Digit')) return code.slice(5)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
||||
const isMac = isMacPlatform()
|
||||
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
|
||||
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
|
||||
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
|
||||
|
||||
return (
|
||||
eventKey === parsed.key &&
|
||||
keyMatches &&
|
||||
!!e.ctrlKey === !!expectedCtrl &&
|
||||
!!e.metaKey === !!expectedMeta &&
|
||||
!!e.shiftKey === !!parsed.shift &&
|
||||
|
||||
@@ -203,3 +203,35 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
|
||||
const { userPermissions } = useWorkspacePermissionsContext()
|
||||
return userPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight permissions provider for sandbox/academy contexts.
|
||||
* Grants full edit access without any API calls or workspace dependencies.
|
||||
*/
|
||||
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
|
||||
const sandboxPermissions = useMemo(
|
||||
(): WorkspacePermissionsContextType => ({
|
||||
workspacePermissions: null,
|
||||
permissionsLoading: false,
|
||||
permissionsError: null,
|
||||
updatePermissions: () => {},
|
||||
refetchPermissions: async () => {},
|
||||
userPermissions: {
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canAdmin: false,
|
||||
userPermissions: 'write',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isOfflineMode: false,
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
|
||||
{children}
|
||||
</WorkspacePermissionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/icons'
|
||||
import { formatAbsoluteDate } from '@/lib/core/utils/formatting'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ScheduleModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal'
|
||||
import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu'
|
||||
@@ -74,6 +87,13 @@ export function ScheduledTasks() {
|
||||
const [activeTask, setActiveTask] = useState<WorkspaceScheduleData | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [scheduleTypeFilter, setScheduleTypeFilter] = useState<string[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([])
|
||||
const [healthFilter, setHealthFilter] = useState<string[]>([])
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() => allItems.filter((item) => item.sourceType === 'job' && item.status !== 'completed'),
|
||||
@@ -81,15 +101,68 @@ export function ScheduledTasks() {
|
||||
)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!debouncedSearchQuery) return visibleItems
|
||||
const q = debouncedSearchQuery.toLowerCase()
|
||||
return visibleItems.filter((item) => {
|
||||
const task = item.prompt || ''
|
||||
return (
|
||||
task.toLowerCase().includes(q) || getScheduleDescription(item).toLowerCase().includes(q)
|
||||
)
|
||||
let result = debouncedSearchQuery
|
||||
? visibleItems.filter((item) => {
|
||||
const task = item.prompt || ''
|
||||
return (
|
||||
task.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
getScheduleDescription(item).toLowerCase().includes(debouncedSearchQuery.toLowerCase())
|
||||
)
|
||||
})
|
||||
: visibleItems
|
||||
|
||||
if (scheduleTypeFilter.length > 0) {
|
||||
result = result.filter((item) => {
|
||||
if (scheduleTypeFilter.includes('recurring') && Boolean(item.cronExpression)) return true
|
||||
if (scheduleTypeFilter.includes('once') && !item.cronExpression) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (statusFilter.length > 0) {
|
||||
result = result.filter((item) => {
|
||||
if (statusFilter.includes('active') && item.status === 'active') return true
|
||||
if (statusFilter.includes('paused') && item.status === 'disabled') return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (healthFilter.includes('has-failures')) {
|
||||
result = result.filter((item) => (item.failedCount ?? 0) > 0)
|
||||
}
|
||||
|
||||
const col = activeSort?.column ?? 'nextRun'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'task':
|
||||
cmp = (a.prompt || '').localeCompare(b.prompt || '')
|
||||
break
|
||||
case 'nextRun':
|
||||
cmp =
|
||||
(a.nextRunAt ? new Date(a.nextRunAt).getTime() : 0) -
|
||||
(b.nextRunAt ? new Date(b.nextRunAt).getTime() : 0)
|
||||
break
|
||||
case 'lastRun':
|
||||
cmp =
|
||||
(a.lastRanAt ? new Date(a.lastRanAt).getTime() : 0) -
|
||||
(b.lastRanAt ? new Date(b.lastRanAt).getTime() : 0)
|
||||
break
|
||||
case 'schedule':
|
||||
cmp = getScheduleDescription(a).localeCompare(getScheduleDescription(b))
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [visibleItems, debouncedSearchQuery])
|
||||
}, [
|
||||
visibleItems,
|
||||
debouncedSearchQuery,
|
||||
scheduleTypeFilter,
|
||||
statusFilter,
|
||||
healthFilter,
|
||||
activeSort,
|
||||
])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
@@ -104,10 +177,6 @@ export function ScheduledTasks() {
|
||||
nextRun: timeCell(item.nextRunAt),
|
||||
lastRun: timeCell(item.lastRanAt),
|
||||
},
|
||||
sortValues: {
|
||||
nextRun: item.nextRunAt ? -new Date(item.nextRunAt).getTime() : 0,
|
||||
lastRun: item.lastRanAt ? -new Date(item.lastRanAt).getTime() : 0,
|
||||
},
|
||||
})),
|
||||
[filteredItems]
|
||||
)
|
||||
@@ -170,6 +239,151 @@ export function ScheduledTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'task', label: 'Task' },
|
||||
{ id: 'schedule', label: 'Schedule' },
|
||||
{ id: 'nextRun', label: 'Next Run' },
|
||||
{ id: 'lastRun', label: 'Last Run' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const scheduleTypeDisplayLabel = useMemo(() => {
|
||||
if (scheduleTypeFilter.length === 0) return 'All'
|
||||
if (scheduleTypeFilter.length === 1)
|
||||
return scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'
|
||||
return `${scheduleTypeFilter.length} selected`
|
||||
}, [scheduleTypeFilter])
|
||||
|
||||
const statusDisplayLabel = useMemo(() => {
|
||||
if (statusFilter.length === 0) return 'All'
|
||||
if (statusFilter.length === 1) return statusFilter[0] === 'active' ? 'Active' : 'Paused'
|
||||
return `${statusFilter.length} selected`
|
||||
}, [statusFilter])
|
||||
|
||||
const healthDisplayLabel = useMemo(() => {
|
||||
if (healthFilter.length === 0) return 'All'
|
||||
return 'Has failures'
|
||||
}, [healthFilter])
|
||||
|
||||
const hasActiveFilters =
|
||||
scheduleTypeFilter.length > 0 || statusFilter.length > 0 || healthFilter.length > 0
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Schedule Type
|
||||
</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'recurring', label: 'Recurring' },
|
||||
{ value: 'once', label: 'One-time' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={scheduleTypeFilter}
|
||||
onMultiSelectChange={setScheduleTypeFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{scheduleTypeDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'paused', label: 'Paused' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={statusFilter}
|
||||
onMultiSelectChange={setStatusFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{statusDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Health</span>
|
||||
<Combobox
|
||||
options={[{ value: 'has-failures', label: 'Has failures' }]}
|
||||
multiSelect
|
||||
multiSelectValues={healthFilter}
|
||||
onMultiSelectChange={setHealthFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{healthDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setScheduleTypeFilter([])
|
||||
setStatusFilter([])
|
||||
setHealthFilter([])
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
scheduleTypeFilter,
|
||||
statusFilter,
|
||||
healthFilter,
|
||||
scheduleTypeDisplayLabel,
|
||||
statusDisplayLabel,
|
||||
healthDisplayLabel,
|
||||
hasActiveFilters,
|
||||
]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
if (scheduleTypeFilter.length > 0) {
|
||||
const label =
|
||||
scheduleTypeFilter.length === 1
|
||||
? `Type: ${scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'}`
|
||||
: `Type: ${scheduleTypeFilter.length} selected`
|
||||
tags.push({ label, onRemove: () => setScheduleTypeFilter([]) })
|
||||
}
|
||||
if (statusFilter.length > 0) {
|
||||
const label =
|
||||
statusFilter.length === 1
|
||||
? `Status: ${statusFilter[0] === 'active' ? 'Active' : 'Paused'}`
|
||||
: `Status: ${statusFilter.length} selected`
|
||||
tags.push({ label, onRemove: () => setStatusFilter([]) })
|
||||
}
|
||||
if (healthFilter.length > 0) {
|
||||
tags.push({ label: 'Health: Has failures', onRemove: () => setHealthFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [scheduleTypeFilter, statusFilter, healthFilter])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
@@ -184,7 +398,9 @@ export function ScheduledTasks() {
|
||||
onChange: setSearchQuery,
|
||||
placeholder: 'Search scheduled tasks...',
|
||||
}}
|
||||
defaultSort='nextRun'
|
||||
sort={sortConfig}
|
||||
filter={filterContent}
|
||||
filterTags={filterTags}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
|
||||
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
@@ -34,6 +34,21 @@ function getResourceHref(
|
||||
|
||||
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file'
|
||||
|
||||
type SortColumn = 'deleted' | 'name' | 'type'
|
||||
|
||||
interface SortConfig {
|
||||
column: SortColumn
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' }
|
||||
|
||||
const SORT_OPTIONS: { column: SortColumn; direction: 'asc' | 'desc'; label: string }[] = [
|
||||
{ column: 'deleted', direction: 'desc', label: 'Deleted (newest first)' },
|
||||
{ column: 'name', direction: 'asc', label: 'Name (A–Z)' },
|
||||
{ column: 'type', direction: 'asc', label: 'Type (A–Z)' },
|
||||
]
|
||||
|
||||
const ICON_CLASS = 'h-[14px] w-[14px]'
|
||||
|
||||
const RESOURCE_TYPE_TO_MOTHERSHIP: Record<Exclude<ResourceType, 'all'>, MothershipResourceType> = {
|
||||
@@ -100,6 +115,7 @@ export function RecentlyDeleted() {
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const [activeTab, setActiveTab] = useState<ResourceType>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeSort, setActiveSort] = useState<SortConfig | null>(null)
|
||||
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
|
||||
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
|
||||
|
||||
@@ -174,7 +190,6 @@ export function RecentlyDeleted() {
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime())
|
||||
return items
|
||||
}, [
|
||||
workflowsQuery.data,
|
||||
@@ -191,10 +206,27 @@ export function RecentlyDeleted() {
|
||||
const normalized = searchTerm.toLowerCase()
|
||||
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
return items
|
||||
}, [resources, activeTab, searchTerm])
|
||||
const col = (activeSort ?? DEFAULT_SORT).column
|
||||
const dir = (activeSort ?? DEFAULT_SORT).direction
|
||||
return [...items].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'type':
|
||||
cmp = a.type.localeCompare(b.type)
|
||||
break
|
||||
case 'deleted':
|
||||
cmp = a.deletedAt.getTime() - b.deletedAt.getTime()
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [resources, activeTab, searchTerm, activeSort])
|
||||
|
||||
const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0
|
||||
const selectedSort = activeSort ?? DEFAULT_SORT
|
||||
|
||||
function handleRestore(resource: DeletedResource) {
|
||||
setRestoringIds((prev) => new Set(prev).add(resource.id))
|
||||
@@ -232,18 +264,41 @@ export function RecentlyDeleted() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-4.5'>
|
||||
<div className='flex items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search deleted items...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search deleted items...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-[190px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
align='end'
|
||||
disabled={isLoading}
|
||||
value={`${selectedSort.column}:${selectedSort.direction}`}
|
||||
onChange={(value) => {
|
||||
const option = SORT_OPTIONS.find(
|
||||
(sortOption) => `${sortOption.column}:${sortOption.direction}` === value
|
||||
)
|
||||
if (option) {
|
||||
setActiveSort({ column: option.column, direction: option.direction })
|
||||
}
|
||||
}}
|
||||
options={SORT_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
value: `${option.column}:${option.direction}`,
|
||||
}))}
|
||||
className='h-[30px] rounded-lg border-[var(--border)] bg-transparent px-2.5 text-small dark:bg-[var(--surface-4)]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SModalTabs value={activeTab} onValueChange={(v) => setActiveTab(v as ResourceType)}>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { FormField } from '@/app/workspace/[workspaceId]/settings/components/mcp/components'
|
||||
import { useCreateWorkflowMcpServer } from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('CreateWorkflowMcpServerModal')
|
||||
|
||||
const INITIAL_FORM_DATA: { name: string; description: string; isPublic: boolean } = {
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
}
|
||||
|
||||
interface CreateWorkflowMcpServerModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workspaceId: string
|
||||
workflowOptions?: ComboboxOption[]
|
||||
isLoadingWorkflows?: boolean
|
||||
}
|
||||
|
||||
export function CreateWorkflowMcpServerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
workflowOptions,
|
||||
isLoadingWorkflows = false,
|
||||
}: CreateWorkflowMcpServerModalProps) {
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
|
||||
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA })
|
||||
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
|
||||
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({ ...INITIAL_FORM_DATA })
|
||||
setSelectedWorkflowIds([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleCreateServer = useCallback(async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isPublic: formData.isPublic,
|
||||
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to create server:', err)
|
||||
}
|
||||
}, [formData, selectedWorkflowIds, workspaceId, onOpenChange])
|
||||
|
||||
const showWorkflows = workflowOptions !== undefined
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Description'>
|
||||
<Textarea
|
||||
placeholder='Describe what this MCP server does (optional)'
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className='min-h-[60px] resize-none'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{showWorkflows && (
|
||||
<FormField label='Workflows'>
|
||||
<Combobox
|
||||
options={workflowOptions ?? []}
|
||||
multiSelect
|
||||
multiSelectValues={selectedWorkflowIds}
|
||||
onMultiSelectChange={setSelectedWorkflowIds}
|
||||
placeholder='Select workflows...'
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={createServerMutation.isPending}
|
||||
emptyMessage='No deployed workflows available'
|
||||
overlayContent={
|
||||
selectedWorkflowIds.length > 0 ? (
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{selectedWorkflowIds.length} workflow
|
||||
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label='Access'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonGroup
|
||||
value={formData.isPublic ? 'public' : 'private'}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, isPublic: value === 'public' })
|
||||
}
|
||||
>
|
||||
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
{formData.isPublic && (
|
||||
<span className='text-[var(--text-muted)] text-xs'>
|
||||
No authentication required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useCreateWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpTool,
|
||||
useDeployedWorkflows,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { CreateApiKeyModal } from '../api-keys/components'
|
||||
import { FormField, McpServerSkeleton } from '../mcp/components'
|
||||
import { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
@@ -955,13 +955,10 @@ export function WorkflowMcpServers() {
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
|
||||
useDeployedWorkflows(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
|
||||
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
|
||||
@@ -979,29 +976,6 @@ export function WorkflowMcpServers() {
|
||||
}))
|
||||
}, [deployedWorkflows])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '', description: '', isPublic: false })
|
||||
setSelectedWorkflowIds([])
|
||||
setShowAddModal(false)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isPublic: formData.isPublic,
|
||||
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
|
||||
})
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
logger.error('Failed to create server:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
@@ -1026,7 +1000,6 @@ export function WorkflowMcpServers() {
|
||||
|
||||
const hasServers = servers.length > 0
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
if (selectedServerId) {
|
||||
return (
|
||||
@@ -1123,86 +1096,13 @@ export function WorkflowMcpServers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showAddModal} onOpenChange={(open) => !open && resetForm()}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Description'>
|
||||
<Textarea
|
||||
placeholder='Describe what this MCP server does (optional)'
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className='min-h-[60px] resize-none'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Workflows'>
|
||||
<Combobox
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedWorkflowIds}
|
||||
onMultiSelectChange={setSelectedWorkflowIds}
|
||||
placeholder='Select workflows...'
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={createServerMutation.isPending}
|
||||
emptyMessage='No deployed workflows available'
|
||||
overlayContent={
|
||||
selectedWorkflowIds.length > 0 ? (
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{selectedWorkflowIds.length} workflow
|
||||
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Access'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonGroup
|
||||
value={formData.isPublic ? 'public' : 'private'}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, isPublic: value === 'public' })
|
||||
}
|
||||
>
|
||||
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
{formData.isPublic && (
|
||||
<span className='text-[var(--text-muted)] text-xs'>
|
||||
No authentication required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CreateWorkflowMcpServerModal
|
||||
open={showAddModal}
|
||||
onOpenChange={setShowAddModal}
|
||||
workspaceId={workspaceId}
|
||||
workflowOptions={workflowOptions}
|
||||
isLoadingWorkflows={isLoadingWorkflows}
|
||||
/>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent size='sm'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
@@ -11,29 +11,29 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { ChevronDown, Plus } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { Filter, FilterRule } from '@/lib/table'
|
||||
import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants'
|
||||
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
|
||||
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
eq: '=',
|
||||
ne: '≠',
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
contains: '∋',
|
||||
in: '∈',
|
||||
} as const
|
||||
const OPERATOR_LABELS = Object.fromEntries(
|
||||
COMPARISON_OPERATORS.map((op) => [op.value, op.label])
|
||||
) as Record<string, string>
|
||||
|
||||
interface TableFilterProps {
|
||||
columns: Array<{ name: string; type: string }>
|
||||
filter: Filter | null
|
||||
onApply: (filter: Filter | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TableFilter({ columns, onApply }: TableFilterProps) {
|
||||
const [rules, setRules] = useState<FilterRule[]>(() => [createRule(columns)])
|
||||
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
|
||||
const [rules, setRules] = useState<FilterRule[]>(() => {
|
||||
const fromFilter = filterToRules(filter)
|
||||
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
|
||||
})
|
||||
|
||||
const rulesRef = useRef(rules)
|
||||
rulesRef.current = rules
|
||||
|
||||
const columnOptions = useMemo(
|
||||
() => columns.map((col) => ({ value: col.name, label: col.name })),
|
||||
@@ -46,52 +46,82 @@ export function TableFilter({ columns, onApply }: TableFilterProps) {
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
setRules((prev) => {
|
||||
const next = prev.filter((r) => r.id !== id)
|
||||
return next.length === 0 ? [createRule(columns)] : next
|
||||
})
|
||||
const next = rulesRef.current.filter((r) => r.id !== id)
|
||||
if (next.length === 0) {
|
||||
onApply(null)
|
||||
onClose()
|
||||
setRules([createRule(columns)])
|
||||
} else {
|
||||
setRules(next)
|
||||
}
|
||||
},
|
||||
[columns]
|
||||
[columns, onApply, onClose]
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => {
|
||||
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
|
||||
}, [])
|
||||
|
||||
const handleToggleLogical = useCallback((id: string) => {
|
||||
setRules((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const validRules = rules.filter((r) => r.column && r.value)
|
||||
const validRules = rulesRef.current.filter((r) => r.column && r.value)
|
||||
onApply(filterRulesToFilter(validRules))
|
||||
}, [rules, onApply])
|
||||
}, [onApply])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setRules([createRule(columns)])
|
||||
onApply(null)
|
||||
}, [columns, onApply])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5 p-2'>
|
||||
{rules.map((rule) => (
|
||||
<FilterRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
columns={columnOptions}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
onApply={handleApply}
|
||||
/>
|
||||
))}
|
||||
<div className='border-[var(--border)] border-b bg-[var(--bg)] px-4 py-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{rules.map((rule, index) => (
|
||||
<FilterRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
isFirst={index === 0}
|
||||
columns={columnOptions}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
onApply={handleApply}
|
||||
onToggleLogical={handleToggleLogical}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleAdd}
|
||||
className={cn(
|
||||
'border border-[var(--border)] border-dashed px-2 py-[3px] text-[var(--text-secondary)] text-xs'
|
||||
)}
|
||||
>
|
||||
<Plus className='mr-1 h-[10px] w-[10px]' />
|
||||
Add filter
|
||||
</Button>
|
||||
|
||||
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
|
||||
Apply filter
|
||||
</Button>
|
||||
<div className='mt-1 flex items-center justify-between'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleAdd}
|
||||
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
|
||||
>
|
||||
<Plus className='mr-1 h-[10px] w-[10px]' />
|
||||
Add filter
|
||||
</Button>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{filter !== null && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleClear}
|
||||
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
|
||||
Apply filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -99,18 +129,39 @@ export function TableFilter({ columns, onApply }: TableFilterProps) {
|
||||
|
||||
interface FilterRuleRowProps {
|
||||
rule: FilterRule
|
||||
isFirst: boolean
|
||||
columns: Array<{ value: string; label: string }>
|
||||
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onApply: () => void
|
||||
onToggleLogical: (id: string) => void
|
||||
}
|
||||
|
||||
function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRuleRowProps) {
|
||||
const FilterRuleRow = memo(function FilterRuleRow({
|
||||
rule,
|
||||
isFirst,
|
||||
columns,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onApply,
|
||||
onToggleLogical,
|
||||
}: FilterRuleRowProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isFirst ? (
|
||||
<span className='w-[42px] shrink-0 text-right text-[var(--text-muted)] text-xs'>Where</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onToggleLogical(rule.id)}
|
||||
className='w-[42px] shrink-0 rounded-full py-0.5 text-right font-medium text-[10px] text-[var(--text-muted)] uppercase tracking-wide transition-colors hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
{rule.logicalOperator}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className='flex h-[30px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
|
||||
<button className='flex h-[28px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
|
||||
<span className='truncate'>{rule.column || 'Column'}</span>
|
||||
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
@@ -129,8 +180,8 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className='flex h-[30px] min-w-[50px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
|
||||
<span>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
|
||||
<button className='flex h-[28px] min-w-[90px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
|
||||
<span className='truncate'>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
|
||||
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -151,25 +202,21 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
|
||||
value={rule.value}
|
||||
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleApply()
|
||||
if (e.key === 'Enter') onApply()
|
||||
}}
|
||||
placeholder='Enter a value'
|
||||
className='h-[30px] min-w-[160px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
|
||||
className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => onRemove(rule.id)}
|
||||
className='flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
|
||||
className='flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
function handleApply() {
|
||||
onApply()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createRule(columns: Array<{ name: string }>): FilterRule {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -305,6 +306,17 @@ export function Table({
|
||||
return 0
|
||||
}, [resizingColumn, displayColumns, columnWidths])
|
||||
|
||||
const dropIndicatorLeft = useMemo(() => {
|
||||
if (!dropTargetColumnName) return null
|
||||
let left = CHECKBOX_COL_WIDTH
|
||||
for (const col of displayColumns) {
|
||||
if (dropSide === 'left' && col.name === dropTargetColumnName) return left
|
||||
left += columnWidths[col.name] ?? COL_WIDTH
|
||||
if (dropSide === 'right' && col.name === dropTargetColumnName) return left
|
||||
}
|
||||
return null
|
||||
}, [dropTargetColumnName, dropSide, displayColumns, columnWidths])
|
||||
|
||||
const isAllRowsSelected = useMemo(() => {
|
||||
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
|
||||
for (const row of rows) {
|
||||
@@ -367,13 +379,11 @@ export function Table({
|
||||
setColumnWidths(updatedWidths)
|
||||
}
|
||||
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
|
||||
if (updatedOrder) {
|
||||
setColumnOrder(updatedOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: updatedWidths,
|
||||
columnOrder: updatedOrder,
|
||||
})
|
||||
}
|
||||
if (updatedOrder) setColumnOrder(updatedOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: updatedWidths,
|
||||
columnOrder: updatedOrder,
|
||||
})
|
||||
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
|
||||
},
|
||||
})
|
||||
@@ -682,6 +692,7 @@ export function Table({
|
||||
}
|
||||
setDragColumnName(null)
|
||||
setDropTargetColumnName(null)
|
||||
setDropSide('left')
|
||||
}, [])
|
||||
|
||||
const handleColumnDragLeave = useCallback(() => {
|
||||
@@ -1340,8 +1351,7 @@ export function Table({
|
||||
|
||||
const insertColumnInOrder = useCallback(
|
||||
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const order = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name)
|
||||
const newOrder = [...order]
|
||||
let anchorIdx = newOrder.indexOf(anchorColumn)
|
||||
if (anchorIdx === -1) {
|
||||
@@ -1422,12 +1432,12 @@ export function Table({
|
||||
const handleDeleteColumnConfirm = useCallback(() => {
|
||||
if (!deletingColumn) return
|
||||
const columnToDelete = deletingColumn
|
||||
const orderAtDelete = columnOrderRef.current
|
||||
setDeletingColumn(null)
|
||||
deleteColumnMutation.mutate(columnToDelete, {
|
||||
onSuccess: () => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const newOrder = order.filter((n) => n !== columnToDelete)
|
||||
if (!orderAtDelete) return
|
||||
const newOrder = orderAtDelete.filter((n) => n !== columnToDelete)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
@@ -1448,6 +1458,17 @@ export function Table({
|
||||
const handleFilterApply = useCallback((filter: Filter | null) => {
|
||||
setQueryOptions((prev) => ({ ...prev, filter }))
|
||||
}, [])
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setFilterOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleFilterClose = useCallback(() => {
|
||||
setFilterOpen(false)
|
||||
}, [])
|
||||
|
||||
const columnOptions = useMemo<ColumnOption[]>(
|
||||
() =>
|
||||
displayColumns.map((col) => ({
|
||||
@@ -1526,11 +1547,6 @@ export function Table({
|
||||
[handleAddColumn, addColumnMutation.isPending]
|
||||
)
|
||||
|
||||
const filterElement = useMemo(
|
||||
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
|
||||
[displayColumns, handleFilterApply]
|
||||
)
|
||||
|
||||
const activeSortState = useMemo(() => {
|
||||
if (!queryOptions.sort) return null
|
||||
const entries = Object.entries(queryOptions.sort)
|
||||
@@ -1597,7 +1613,19 @@ export function Table({
|
||||
<>
|
||||
<ResourceHeader icon={TableIcon} breadcrumbs={breadcrumbs} create={createAction} />
|
||||
|
||||
<ResourceOptionsBar sort={sortConfig} filter={filterElement} />
|
||||
<ResourceOptionsBar
|
||||
sort={sortConfig}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
filterActive={filterOpen || !!queryOptions.filter}
|
||||
/>
|
||||
{filterOpen && (
|
||||
<TableFilter
|
||||
columns={displayColumns}
|
||||
filter={queryOptions.filter}
|
||||
onApply={handleFilterApply}
|
||||
onClose={handleFilterClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1677,8 +1705,6 @@ export function Table({
|
||||
onResize={handleColumnResize}
|
||||
onResizeEnd={handleColumnResizeEnd}
|
||||
isDragging={dragColumnName === column.name}
|
||||
isDropTarget={dropTargetColumnName === column.name}
|
||||
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
|
||||
onDragStart={handleColumnDragStart}
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
@@ -1701,7 +1727,7 @@ export function Table({
|
||||
<>
|
||||
{rows.map((row, index) => {
|
||||
const prevPosition = index > 0 ? rows[index - 1].position : -1
|
||||
const gapCount = row.position - prevPosition - 1
|
||||
const gapCount = queryOptions.filter ? 0 : row.position - prevPosition - 1
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
{gapCount > 0 && (
|
||||
@@ -1755,6 +1781,12 @@ export function Table({
|
||||
style={{ left: resizeIndicatorLeft }}
|
||||
/>
|
||||
)}
|
||||
{dropIndicatorLeft !== null && (
|
||||
<div
|
||||
className='-translate-x-[1px] pointer-events-none absolute top-0 z-20 h-full w-[2px] bg-[var(--selection)]'
|
||||
style={{ left: dropIndicatorLeft }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && (
|
||||
<AddRowButton onClick={handleAppendRow} />
|
||||
@@ -2581,8 +2613,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
dropSide,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
@@ -2605,8 +2635,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResize: (columnName: string, width: number) => void
|
||||
onResizeEnd: () => void
|
||||
isDragging?: boolean
|
||||
isDropTarget?: boolean
|
||||
dropSide?: 'left' | 'right'
|
||||
onDragStart?: (columnName: string) => void
|
||||
onDragOver?: (columnName: string, side: 'left' | 'right') => void
|
||||
onDragEnd?: () => void
|
||||
@@ -2698,22 +2726,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
|
||||
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
|
||||
isDragging && 'opacity-40'
|
||||
)}
|
||||
draggable={!readOnly && !isRenaming}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{isDropTarget && dropSide === 'left' && (
|
||||
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isDropTarget && dropSide === 'right' && (
|
||||
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
@@ -2738,63 +2757,73 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-full w-full min-w-0 cursor-pointer items-center px-2 py-[7px] outline-none active:cursor-grabbing'
|
||||
>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{column.name}
|
||||
</span>
|
||||
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
|
||||
<Pencil />
|
||||
Rename column
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)}
|
||||
Change type
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{COLUMN_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.type}
|
||||
disabled={column.type === option.type}
|
||||
onSelect={() => onChangeType(column.name, option.type)}
|
||||
>
|
||||
<option.icon />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
|
||||
<ArrowLeft />
|
||||
Insert column left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onInsertRight(column.name)}>
|
||||
<ArrowRight />
|
||||
Insert column right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onToggleUnique(column.name)}>
|
||||
<Fingerprint />
|
||||
{column.unique ? 'Remove unique' : 'Set unique'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onDeleteColumn(column.name)}>
|
||||
<Trash />
|
||||
Delete column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className='flex h-full w-full min-w-0 items-center'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none'
|
||||
>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{column.name}
|
||||
</span>
|
||||
<ChevronDown className='ml-1.5 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
|
||||
<Pencil />
|
||||
Rename column
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)}
|
||||
Change type
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{COLUMN_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.type}
|
||||
disabled={column.type === option.type}
|
||||
onSelect={() => onChangeType(column.name, option.type)}
|
||||
>
|
||||
<option.icon />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
|
||||
<ArrowLeft />
|
||||
Insert column left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onInsertRight(column.name)}>
|
||||
<ArrowRight />
|
||||
Insert column right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onToggleUnique(column.name)}>
|
||||
<Fingerprint />
|
||||
{column.unique ? 'Remove unique' : 'Set unique'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onDeleteColumn(column.name)}>
|
||||
<Trash />
|
||||
Delete column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className='flex h-full cursor-grab items-center pr-1.5 pl-0.5 opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100'
|
||||
>
|
||||
<GripVertical className='h-3 w-3 shrink-0 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import type { ComboboxOption } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -16,7 +18,13 @@ import {
|
||||
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { generateUniqueTableName } from '@/lib/table/constants'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
|
||||
@@ -29,6 +37,7 @@ import {
|
||||
useUploadCsvToTable,
|
||||
} from '@/hooks/queries/tables'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Tables')
|
||||
|
||||
@@ -60,6 +69,13 @@ export function Tables() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300)
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const [rowCountFilter, setRowCountFilter] = useState<string[]>([])
|
||||
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -78,15 +94,56 @@ export function Tables() {
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!searchTerm) return tables
|
||||
const term = searchTerm.toLowerCase()
|
||||
return tables.filter((table) => table.name.toLowerCase().includes(term))
|
||||
}, [tables, searchTerm])
|
||||
const processedTables = useMemo(() => {
|
||||
let result = debouncedSearchTerm
|
||||
? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
|
||||
: tables
|
||||
|
||||
if (rowCountFilter.length > 0) {
|
||||
result = result.filter((t) => {
|
||||
if (rowCountFilter.includes('empty') && t.rowCount === 0) return true
|
||||
if (rowCountFilter.includes('small') && t.rowCount >= 1 && t.rowCount <= 100) return true
|
||||
if (rowCountFilter.includes('large') && t.rowCount > 100) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
if (ownerFilter.length > 0) {
|
||||
result = result.filter((t) => ownerFilter.includes(t.createdBy))
|
||||
}
|
||||
const col = activeSort?.column ?? 'created'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'columns':
|
||||
cmp = a.schema.columns.length - b.schema.columns.length
|
||||
break
|
||||
case 'rows':
|
||||
cmp = a.rowCount - b.rowCount
|
||||
break
|
||||
case 'created':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
break
|
||||
case 'updated':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
break
|
||||
case 'owner': {
|
||||
const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? ''
|
||||
const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? ''
|
||||
cmp = aName.localeCompare(bName)
|
||||
break
|
||||
}
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredTables.map((table) => ({
|
||||
processedTables.map((table) => ({
|
||||
id: table.id,
|
||||
cells: {
|
||||
name: {
|
||||
@@ -105,16 +162,167 @@ export function Tables() {
|
||||
owner: ownerCell(table.createdBy, members),
|
||||
updated: timeCell(table.updatedAt),
|
||||
},
|
||||
sortValues: {
|
||||
columns: table.schema.columns.length,
|
||||
rows: table.rowCount,
|
||||
created: -new Date(table.createdAt).getTime(),
|
||||
updated: -new Date(table.updatedAt).getTime(),
|
||||
},
|
||||
})),
|
||||
[filteredTables, members]
|
||||
[processedTables, members]
|
||||
)
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
onClearAll: () => setSearchTerm(''),
|
||||
placeholder: 'Search tables...',
|
||||
}),
|
||||
[searchTerm]
|
||||
)
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
options: [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'columns', label: 'Columns' },
|
||||
{ id: 'rows', label: 'Rows' },
|
||||
{ id: 'created', label: 'Created' },
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
{ id: 'updated', label: 'Last Updated' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => setActiveSort({ column, direction }),
|
||||
onClear: () => setActiveSort(null),
|
||||
}),
|
||||
[activeSort]
|
||||
)
|
||||
|
||||
const rowCountDisplayLabel = useMemo(() => {
|
||||
if (rowCountFilter.length === 0) return 'All'
|
||||
if (rowCountFilter.length === 1) {
|
||||
const labels: Record<string, string> = {
|
||||
empty: 'Empty',
|
||||
small: 'Small (1–100)',
|
||||
large: 'Large (101+)',
|
||||
}
|
||||
return labels[rowCountFilter[0]] ?? rowCountFilter[0]
|
||||
}
|
||||
return `${rowCountFilter.length} selected`
|
||||
}, [rowCountFilter])
|
||||
|
||||
const ownerDisplayLabel = useMemo(() => {
|
||||
if (ownerFilter.length === 0) return 'All'
|
||||
if (ownerFilter.length === 1)
|
||||
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
|
||||
return `${ownerFilter.length} members`
|
||||
}, [ownerFilter, members])
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
(members ?? []).map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.name,
|
||||
iconElement: m.image ? (
|
||||
<img
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
referrerPolicy='no-referrer'
|
||||
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[members]
|
||||
)
|
||||
|
||||
const hasActiveFilters = rowCountFilter.length > 0 || ownerFilter.length > 0
|
||||
|
||||
const filterContent = useMemo(
|
||||
() => (
|
||||
<div className='flex w-[240px] flex-col gap-3 p-3'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Row Count</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'empty', label: 'Empty' },
|
||||
{ value: 'small', label: 'Small (1–100 rows)' },
|
||||
{ value: 'large', label: 'Large (101+ rows)' },
|
||||
]}
|
||||
multiSelect
|
||||
multiSelectValues={rowCountFilter}
|
||||
onMultiSelectChange={setRowCountFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
{memberOptions.length > 0 && (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
|
||||
<Combobox
|
||||
options={memberOptions}
|
||||
multiSelect
|
||||
multiSelectValues={ownerFilter}
|
||||
onMultiSelectChange={setOwnerFilter}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search members...'
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
size='sm'
|
||||
className='h-[32px] w-full rounded-md'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setRowCountFilter([])
|
||||
setOwnerFilter([])
|
||||
}}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
rowCountFilter,
|
||||
ownerFilter,
|
||||
memberOptions,
|
||||
rowCountDisplayLabel,
|
||||
ownerDisplayLabel,
|
||||
hasActiveFilters,
|
||||
]
|
||||
)
|
||||
|
||||
const filterTags: FilterTag[] = useMemo(() => {
|
||||
const tags: FilterTag[] = []
|
||||
if (rowCountFilter.length > 0) {
|
||||
const rowLabels: Record<string, string> = { empty: 'Empty', small: 'Small', large: 'Large' }
|
||||
const label =
|
||||
rowCountFilter.length === 1
|
||||
? `Rows: ${rowLabels[rowCountFilter[0]]}`
|
||||
: `Rows: ${rowCountFilter.length} selected`
|
||||
tags.push({ label, onRemove: () => setRowCountFilter([]) })
|
||||
}
|
||||
if (ownerFilter.length > 0) {
|
||||
const label =
|
||||
ownerFilter.length === 1
|
||||
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
|
||||
: `Owner: ${ownerFilter.length} members`
|
||||
tags.push({ label, onRemove: () => setOwnerFilter([]) })
|
||||
}
|
||||
return tags
|
||||
}, [rowCountFilter, ownerFilter, members])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -215,7 +423,7 @@ export function Tables() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, router]
|
||||
[workspaceId, router, uploadCsv]
|
||||
)
|
||||
|
||||
const handleListUploadCsv = useCallback(() => {
|
||||
@@ -260,12 +468,10 @@ export function Tables() {
|
||||
onClick: handleCreateTable,
|
||||
disabled: uploading || userPermissions.canEdit !== true || createTable.isPending,
|
||||
}}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search tables...',
|
||||
}}
|
||||
defaultSort='created'
|
||||
search={searchConfig}
|
||||
sort={sortConfig}
|
||||
filter={filterContent}
|
||||
filterTags={filterTags}
|
||||
headerActions={[
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'add-workflow'
|
||||
| 'add-task'
|
||||
// | 'goto-templates'
|
||||
| 'goto-logs'
|
||||
| 'open-search'
|
||||
@@ -52,6 +54,16 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
shortcut: 'Mod+Shift+A',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'add-workflow': {
|
||||
id: 'add-workflow',
|
||||
shortcut: 'Mod+Shift+P',
|
||||
allowInEditable: false,
|
||||
},
|
||||
'add-task': {
|
||||
id: 'add-task',
|
||||
shortcut: 'Mod+Shift+K',
|
||||
allowInEditable: false,
|
||||
},
|
||||
// 'goto-templates': {
|
||||
// id: 'goto-templates',
|
||||
// shortcut: 'Mod+Y',
|
||||
|
||||
@@ -320,5 +320,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
clearAttachedFiles,
|
||||
processFiles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/create-workflow-mcp-server-modal'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -102,11 +100,7 @@ export function McpDeploy({
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [showMcpModal, setShowMcpModal] = useState(false)
|
||||
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
@@ -473,22 +467,16 @@ export function McpDeploy({
|
||||
<>
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
Create an MCP Server to expose your workflows as tools.
|
||||
</p>
|
||||
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
|
||||
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<McpServerFormModal
|
||||
open={showMcpModal}
|
||||
onOpenChange={setShowMcpModal}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
<CreateWorkflowMcpServerModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
|
||||
import { useToolbarStore } from '@/stores/panel'
|
||||
|
||||
interface BlockItem {
|
||||
@@ -348,12 +349,20 @@ export const Toolbar = memo(
|
||||
})
|
||||
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
const sandboxAllowedBlocks = useSandboxBlockConstraints()
|
||||
|
||||
const allTriggers = getTriggers()
|
||||
const allBlocks = getBlocks()
|
||||
|
||||
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
|
||||
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
|
||||
const blocks = useMemo(() => {
|
||||
const permitted = filterBlocks(allBlocks)
|
||||
if (sandboxAllowedBlocks === null) return permitted
|
||||
return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
|
||||
}, [filterBlocks, allBlocks, sandboxAllowedBlocks])
|
||||
const triggers = useMemo(() => {
|
||||
if (sandboxAllowedBlocks !== null) return []
|
||||
return filterBlocks(allTriggers)
|
||||
}, [filterBlocks, allTriggers, sandboxAllowedBlocks])
|
||||
|
||||
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
|
||||
|
||||
|
||||
@@ -89,10 +89,15 @@ const logger = createLogger('Panel')
|
||||
*
|
||||
* @returns Panel on the right side of the workflow
|
||||
*/
|
||||
export const Panel = memo(function Panel() {
|
||||
interface PanelProps {
|
||||
/** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
|
||||
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface WorkflowBlockProps {
|
||||
isPreviewSelected?: boolean
|
||||
/** Whether this block is rendered inside an embedded (read-only) workflow view */
|
||||
isEmbedded?: boolean
|
||||
/** Whether this block is rendered inside a sandbox (academy exercise) view with no workspace API calls */
|
||||
isSandbox?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
@@ -855,13 +855,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<WorkflowBlockProps>) {
|
||||
const { type, config, name, isPending } = data
|
||||
const { type, config, name, isPending, isSandbox } = data
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const params = useParams()
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
const workspaceId = params.workspaceId as string
|
||||
// In sandbox mode pass empty strings so all workspace-scoped queries are disabled
|
||||
const currentWorkflowId = isSandbox ? '' : (params.workflowId as string)
|
||||
const workspaceId = isSandbox ? '' : (params.workspaceId as string)
|
||||
|
||||
const {
|
||||
currentWorkflow,
|
||||
|
||||
@@ -380,6 +380,13 @@ export function useWorkflowExecution() {
|
||||
async (workflowInput?: any, enableDebug = false) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
// Sandbox exercises have no real workflow — signal the SandboxCanvasProvider
|
||||
// to run mock execution by setting isExecuting, then bail out immediately.
|
||||
if (workflows[activeWorkflowId]?.isSandbox) {
|
||||
setIsExecuting(activeWorkflowId, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspaceId from workflow metadata
|
||||
const workspaceId = workflows[activeWorkflowId]?.workspaceId
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ interface WorkflowContentProps {
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
embedded?: boolean
|
||||
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
const WorkflowContent = React.memo(
|
||||
@@ -238,6 +240,7 @@ const WorkflowContent = React.memo(
|
||||
workspaceId: propWorkspaceId,
|
||||
workflowId: propWorkflowId,
|
||||
embedded,
|
||||
sandbox,
|
||||
}: WorkflowContentProps = {}) => {
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
@@ -327,7 +330,7 @@ const WorkflowContent = React.memo(
|
||||
const snapToGridSize = useSnapToGridSize()
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
|
||||
const isAutoConnectEnabled = useAutoConnect()
|
||||
const isAutoConnectEnabled = useAutoConnect() && !sandbox
|
||||
const autoConnectRef = useRef(isAutoConnectEnabled)
|
||||
autoConnectRef.current = isAutoConnectEnabled
|
||||
|
||||
@@ -1236,7 +1239,7 @@ const WorkflowContent = React.memo(
|
||||
clearLockNotification()
|
||||
}
|
||||
|
||||
if (allBlocksLocked) {
|
||||
if (allBlocksLocked && !sandbox) {
|
||||
if (lockNotificationIdRef.current) return
|
||||
|
||||
const isAdmin = effectivePermissions.canAdmin
|
||||
@@ -2192,6 +2195,9 @@ const WorkflowContent = React.memo(
|
||||
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
||||
|
||||
useEffect(() => {
|
||||
// In sandbox mode the stores are pre-hydrated externally; skip the API load.
|
||||
if (sandbox) return
|
||||
|
||||
const currentId = workflowIdParam
|
||||
const currentWorkspaceHydration = hydration.workspaceId
|
||||
|
||||
@@ -2260,13 +2266,13 @@ const WorkflowContent = React.memo(
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
useWorkspaceEnvironment(workspaceId)
|
||||
useWorkspaceEnvironment(sandbox ? '' : workspaceId)
|
||||
|
||||
const workflowCount = useMemo(() => Object.keys(workflows).length, [workflows])
|
||||
|
||||
/** Handles navigation validation and redirects for invalid workflow IDs. */
|
||||
useEffect(() => {
|
||||
if (embedded) return
|
||||
if (embedded || sandbox) return
|
||||
|
||||
// Wait for metadata to finish loading before making navigation decisions
|
||||
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
|
||||
@@ -2451,6 +2457,7 @@ const WorkflowContent = React.memo(
|
||||
isActive,
|
||||
isPending,
|
||||
...(embedded && { isEmbedded: true }),
|
||||
...(sandbox && { isSandbox: true }),
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
@@ -2463,7 +2470,16 @@ const WorkflowContent = React.memo(
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [blocksStructureHash, blocks, activeBlockIds, pendingBlocks, isDebugging, getBlockConfig])
|
||||
}, [
|
||||
blocksStructureHash,
|
||||
blocks,
|
||||
activeBlockIds,
|
||||
pendingBlocks,
|
||||
isDebugging,
|
||||
getBlockConfig,
|
||||
sandbox,
|
||||
embedded,
|
||||
])
|
||||
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
@@ -4096,9 +4112,9 @@ const WorkflowContent = React.memo(
|
||||
<Terminal />
|
||||
</div>
|
||||
|
||||
{!embedded && <Panel />}
|
||||
{(!embedded || sandbox) && <Panel workspaceId={sandbox ? workspaceId : undefined} />}
|
||||
|
||||
{!embedded && oauthModal && (
|
||||
{!embedded && !sandbox && oauthModal && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyOAuthRequiredModal
|
||||
isOpen={true}
|
||||
@@ -4122,18 +4138,27 @@ interface WorkflowProps {
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
embedded?: boolean
|
||||
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
/** Workflow page with ReactFlowProvider and error boundary wrapper. */
|
||||
const Workflow = React.memo(({ workspaceId, workflowId, embedded }: WorkflowProps = {}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ErrorBoundary>
|
||||
<WorkflowContent workspaceId={workspaceId} workflowId={workflowId} embedded={embedded} />
|
||||
</ErrorBoundary>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
})
|
||||
const Workflow = React.memo(
|
||||
({ workspaceId, workflowId, embedded, sandbox }: WorkflowProps = {}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ErrorBoundary>
|
||||
<WorkflowContent
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
embedded={embedded}
|
||||
sandbox={sandbox}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Workflow.displayName = 'Workflow'
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ function TaskStatusIcon({
|
||||
function WorkflowColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
@@ -161,7 +161,7 @@ export function CollapsedSidebarMenu({
|
||||
<button
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover-hover:bg-[var(--surface-hover)]'
|
||||
className='mx-0.5 flex h-[30px] items-center rounded-lg px-2 hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
@@ -245,9 +245,6 @@ export function CollapsedTaskFlyoutItem({
|
||||
title={task.name}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
statusIndicatorClassName={
|
||||
!(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{showActions && (
|
||||
|
||||
@@ -343,7 +343,7 @@ export function SearchModal({
|
||||
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
|
||||
open ? 'visible opacity-100' : 'invisible opacity-0'
|
||||
)}
|
||||
style={{ left: '50%' }}
|
||||
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
|
||||
>
|
||||
<Command label='Search' shouldFilter={false}>
|
||||
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { ChevronDown, Skeleton, Tooltip } from '@/components/emcn'
|
||||
import { ChevronDown, Skeleton } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isBillingEnabled,
|
||||
sectionConfig,
|
||||
} from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
@@ -186,31 +187,24 @@ export function SettingsSidebar({
|
||||
<>
|
||||
{/* Back button */}
|
||||
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
|
||||
<Tooltip.Root key={`back-${isCollapsed}`}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleBack}
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
|
||||
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
|
||||
</div>
|
||||
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Back</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleBack}
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
|
||||
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
|
||||
</div>
|
||||
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
|
||||
{/* Settings sections */}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3.5 flex flex-1 flex-col gap-3.5',
|
||||
'mt-3.5 flex flex-1 flex-col gap-3.5 pb-2',
|
||||
!isCollapsed && 'overflow-y-auto overflow-x-hidden'
|
||||
)}
|
||||
>
|
||||
@@ -303,14 +297,13 @@ export function SettingsSidebar({
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
|
||||
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{item.label}</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<SidebarTooltip
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
label={item.label}
|
||||
enabled={showCollapsedTooltips}
|
||||
>
|
||||
{element}
|
||||
</SidebarTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -343,7 +343,7 @@ export function WorkspaceHeader({
|
||||
type='button'
|
||||
aria-label='Switch workspace'
|
||||
className={cn(
|
||||
'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-1.5 transition-colors hover-hover:bg-[var(--surface-5)]',
|
||||
'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-[5px] transition-colors hover-hover:bg-[var(--surface-5)]',
|
||||
isCollapsed ? 'w-[32px]' : 'w-full cursor-pointer gap-2 pr-2'
|
||||
)}
|
||||
title={activeWorkspace?.name || 'Loading...'}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ export const ImapBlock: BlockConfig = {
|
||||
host: { type: 'string', description: 'IMAP server hostname' },
|
||||
port: { type: 'string', description: 'IMAP server port' },
|
||||
secure: { type: 'boolean', description: 'Use SSL/TLS encryption' },
|
||||
rejectUnauthorized: { type: 'boolean', description: 'Verify TLS certificate' },
|
||||
username: { type: 'string', description: 'Email username' },
|
||||
password: { type: 'string', description: 'Email password' },
|
||||
mailbox: { type: 'string', description: 'Mailbox to monitor' },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user