mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
47 Commits
feat/new-f
...
v0.6.16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7ae906bc | ||
|
|
c4f4e6b48c | ||
|
|
560fa75155 | ||
|
|
1728c370de | ||
|
|
82e58a5082 | ||
|
|
336c065234 | ||
|
|
b3713642b2 | ||
|
|
b9b930bb63 | ||
|
|
f1ead2ed55 | ||
|
|
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 |
@@ -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,7 +26,7 @@ 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: 'Academy', href: '/academy' },
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
|
||||
{ label: 'Changelog', href: '/changelog' },
|
||||
|
||||
@@ -152,12 +152,13 @@ export default async function PartnersPage() {
|
||||
recognition in the growing ecosystem of AI workflow builders.
|
||||
</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Link
|
||||
{/* 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>
|
||||
</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]'
|
||||
@@ -275,12 +276,13 @@ export default async function PartnersPage() {
|
||||
Complete Sim Academy to earn your first certification and unlock partner benefits.
|
||||
It's free to start — no credit card required.
|
||||
</p>
|
||||
<Link
|
||||
{/* 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>
|
||||
</Link> */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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: {
|
||||
@@ -17,6 +21,10 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +2,7 @@ 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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -432,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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo, useRef } 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'
|
||||
@@ -70,34 +71,51 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
|
||||
/**
|
||||
* 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 }: any) => (
|
||||
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>
|
||||
),
|
||||
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 (
|
||||
@@ -119,8 +137,8 @@ const STATIC_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'
|
||||
@@ -130,102 +148,133 @@ const STATIC_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 buildMarkdownComponents(
|
||||
checkboxCounterRef: React.MutableRefObject<number>,
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
) {
|
||||
const isInteractive = Boolean(onCheckboxToggle)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
ul: ({ className, children }: any) => {
|
||||
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>
|
||||
)
|
||||
},
|
||||
ol: ({ className, children }: any) => {
|
||||
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>
|
||||
)
|
||||
},
|
||||
li: ({ className, children }: any) => {
|
||||
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
|
||||
if (isTaskItem) {
|
||||
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>
|
||||
}
|
||||
return <li className='break-words leading-[1.6]'>{children}</li>
|
||||
},
|
||||
input: ({ type, checked, ...props }: any) => {
|
||||
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
|
||||
|
||||
const index = checkboxCounterRef.current++
|
||||
|
||||
const before = ctx.contentRef.current.slice(0, offset)
|
||||
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked ?? false}
|
||||
onCheckedChange={
|
||||
isInteractive
|
||||
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
|
||||
: undefined
|
||||
}
|
||||
disabled={!isInteractive}
|
||||
size='sm'
|
||||
className='mt-1 shrink-0'
|
||||
/>
|
||||
<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,
|
||||
@@ -238,32 +287,33 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
const { ref: scrollRef } = useAutoScroll(isStreaming)
|
||||
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
|
||||
|
||||
const checkboxCounterRef = useRef(0)
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const components = useMemo(
|
||||
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
|
||||
const ctxValue = useMemo(
|
||||
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
checkboxCounterRef.current = 0
|
||||
|
||||
const committedMarkdown = useMemo(
|
||||
() =>
|
||||
committed ? (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{committed}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
[committed, components]
|
||||
[committed]
|
||||
)
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,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={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)
|
||||
|
||||
|
||||
@@ -571,19 +571,19 @@ export function UserInput({
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const imageFiles: File[] = []
|
||||
const pastedFiles: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
if (file) pastedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length === 0) return
|
||||
if (pastedFiles.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
const dt = new DataTransfer()
|
||||
for (const file of imageFiles) {
|
||||
for (const file of pastedFiles) {
|
||||
dt.items.add(file)
|
||||
}
|
||||
filesRef.current.processFiles(dt.files)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,25 +187,18 @@ 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 */}
|
||||
@@ -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>
|
||||
|
||||
@@ -100,6 +100,28 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
|
||||
export function SidebarTooltip({
|
||||
children,
|
||||
label,
|
||||
enabled,
|
||||
side = 'right',
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
label: string
|
||||
enabled: boolean
|
||||
side?: 'right' | 'bottom'
|
||||
}) {
|
||||
if (!enabled) return children
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Content side={side}>
|
||||
<p>{label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarItemSkeleton() {
|
||||
return (
|
||||
<div className='sidebar-collapse-hide mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2'>
|
||||
@@ -135,71 +157,61 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Link
|
||||
href={task.href}
|
||||
className={cn(
|
||||
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
|
||||
!(isCurrentRoute || isSelected || isMenuOpen) &&
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (task.id === 'new') return
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
|
||||
} else {
|
||||
useFolderStore.setState({
|
||||
selectedTasks: new Set<string>(),
|
||||
lastSelectedTaskId: task.id,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>
|
||||
{task.name}
|
||||
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
|
||||
<Link
|
||||
href={task.href}
|
||||
className={cn(
|
||||
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
|
||||
!(isCurrentRoute || isSelected || isMenuOpen) && 'hover-hover:bg-[var(--surface-hover)]',
|
||||
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (task.id === 'new') return
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
|
||||
} else {
|
||||
useFolderStore.setState({
|
||||
selectedTasks: new Set<string>(),
|
||||
lastSelectedTaskId: task.id,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>{task.name}</div>
|
||||
{task.id !== 'new' && (
|
||||
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
|
||||
{isActive && !isCurrentRoute && !isMenuOpen && (
|
||||
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
|
||||
)}
|
||||
{isActive && !isCurrentRoute && !isMenuOpen && (
|
||||
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
|
||||
)}
|
||||
{!isActive && isUnread && !isCurrentRoute && !isMenuOpen && (
|
||||
<span className='absolute h-[7px] w-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Task options'
|
||||
onPointerDown={onMorePointerDown}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMoreClick(e, task.id)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
|
||||
isMenuOpen && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</div>
|
||||
{task.id !== 'new' && (
|
||||
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
|
||||
{isActive && !isCurrentRoute && (
|
||||
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
|
||||
)}
|
||||
{isActive && !isCurrentRoute && (
|
||||
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
|
||||
)}
|
||||
{!isActive && isUnread && !isCurrentRoute && (
|
||||
<span className='absolute h-[7px] w-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Task options'
|
||||
onPointerDown={onMorePointerDown}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMoreClick(e, task.id)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
|
||||
isMenuOpen && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{task.name}</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarTooltip>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -265,15 +277,12 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
</button>
|
||||
) : null
|
||||
|
||||
if (!element) return null
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{item.label}</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<SidebarTooltip label={item.label} enabled={showCollapsedTooltips}>
|
||||
{element}
|
||||
</SidebarTooltip>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -317,6 +326,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
|
||||
const _hasHydrated = useSidebarStore((state) => state._hasHydrated)
|
||||
const isOnWorkflowPage = !!workflowId
|
||||
|
||||
const isCollapsedRef = useRef(isCollapsed)
|
||||
@@ -326,14 +336,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
|
||||
// Delay collapsed tooltips until the width transition finishes.
|
||||
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isCollapsed) {
|
||||
document.documentElement.removeAttribute('data-sidebar-collapsed')
|
||||
}
|
||||
}, [isCollapsed])
|
||||
if (!_hasHydrated) return
|
||||
document.documentElement.removeAttribute('data-sidebar-collapsed')
|
||||
}, [_hasHydrated])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed) {
|
||||
@@ -1010,10 +1018,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[importWorkspace]
|
||||
)
|
||||
|
||||
// ── Memoised elements & objects for collapsed menus ──
|
||||
// Prevents new JSX/object references on every render, which would defeat
|
||||
// React.memo on CollapsedSidebarMenu and its children.
|
||||
|
||||
const tasksCollapsedIcon = useMemo(
|
||||
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
|
||||
[]
|
||||
@@ -1054,7 +1058,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[handleCreateWorkflow]
|
||||
)
|
||||
|
||||
// Stable no-op for collapsed workflow context menu delete (never changes)
|
||||
const noop = useCallback(() => {}, [])
|
||||
|
||||
const handleExpandSidebar = useCallback(
|
||||
@@ -1065,16 +1068,13 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[toggleCollapsed]
|
||||
)
|
||||
|
||||
// Stable callback for the "New task" button in expanded mode
|
||||
const handleNewTask = useCallback(
|
||||
() => navigateToPage(`/workspace/${workspaceId}/home`),
|
||||
[navigateToPage, workspaceId]
|
||||
)
|
||||
|
||||
// Stable callback for "See more" tasks
|
||||
const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), [])
|
||||
|
||||
// Stable callback for DeleteModal close
|
||||
const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), [])
|
||||
|
||||
const handleEdgeKeyDown = useCallback(
|
||||
@@ -1087,16 +1087,13 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[isCollapsed, toggleCollapsed]
|
||||
)
|
||||
|
||||
// Stable handler for help modal open from dropdown
|
||||
const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), [])
|
||||
|
||||
// Stable handler for opening docs
|
||||
const handleOpenDocs = useCallback(
|
||||
() => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'),
|
||||
[]
|
||||
)
|
||||
|
||||
// Stable blur handlers for inline rename inputs
|
||||
const handleTaskRenameBlur = useCallback(
|
||||
() => void taskFlyoutRename.saveRename(),
|
||||
[taskFlyoutRename.saveRename]
|
||||
@@ -1107,7 +1104,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[workflowFlyoutRename.saveRename]
|
||||
)
|
||||
|
||||
// Stable style for hidden file inputs
|
||||
const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, [])
|
||||
|
||||
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
|
||||
@@ -1205,69 +1201,68 @@ export const Sidebar = memo(function Sidebar() {
|
||||
onClick={handleSidebarClick}
|
||||
>
|
||||
<div className='flex h-full flex-col pt-3'>
|
||||
{/* Top bar: Logo + Collapse toggle */}
|
||||
<div className='flex flex-shrink-0 items-center pr-2 pb-2 pl-2.5'>
|
||||
<div className='flex h-[30px] items-center'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='relative h-[30px]'>
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/home`}
|
||||
className='sidebar-collapse-hide !transition-none group flex h-[30px] items-center rounded-[8px] px-[7px] hover-hover:bg-[var(--surface-hover)]'
|
||||
tabIndex={isCollapsed ? -1 : undefined}
|
||||
aria-label={brand.name}
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
width={16}
|
||||
height={16}
|
||||
className='h-[16px] w-[16px] flex-shrink-0 object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
|
||||
)}
|
||||
</Link>
|
||||
<SidebarTooltip label='Expand sidebar' enabled={showCollapsedTooltips}>
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/home`}
|
||||
onClick={isCollapsed ? handleExpandSidebar : undefined}
|
||||
className='group flex h-[30px] items-center rounded-[8px] px-1.5 hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : brand.name}
|
||||
onClick={handleExpandSidebar}
|
||||
className='sidebar-collapse-show !transition-none group absolute top-0 left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover-hover:bg-[var(--surface-hover)]'
|
||||
tabIndex={isCollapsed ? undefined : -1}
|
||||
aria-label='Expand sidebar'
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
alt=''
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
'h-[16px] w-[16px] flex-shrink-0 object-contain',
|
||||
isCollapsed && 'group-hover:hidden'
|
||||
)}
|
||||
className='h-[16px] w-[16px] flex-shrink-0 object-contain group-hover:hidden'
|
||||
unoptimized
|
||||
/>
|
||||
) : isCollapsed ? (
|
||||
<Sim className='h-[16px] w-[16px] flex-shrink-0 group-hover:hidden' />
|
||||
) : (
|
||||
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<PanelLeft className='hidden h-[16px] w-[16px] flex-shrink-0 rotate-180 text-[var(--text-icon)] group-hover:block' />
|
||||
<Sim className='h-[16px] w-[16px] flex-shrink-0 group-hover:hidden' />
|
||||
)}
|
||||
<PanelLeft className='hidden h-[16px] w-[16px] rotate-180 text-[var(--text-icon)] group-hover:block' />
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Expand sidebar</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleCollapsed}
|
||||
className={cn(
|
||||
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
|
||||
)}
|
||||
aria-label='Collapse sidebar'
|
||||
>
|
||||
<PanelLeft className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{!isCollapsed && (
|
||||
<Tooltip.Content side='bottom'>
|
||||
<p>Collapse sidebar</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<SidebarTooltip label='Collapse sidebar' enabled={!isCollapsed} side='bottom'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleCollapsed}
|
||||
className={cn(
|
||||
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
|
||||
)}
|
||||
aria-label='Collapse sidebar'
|
||||
>
|
||||
<PanelLeft className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 pr-2.5 pl-[9px]'>
|
||||
<WorkspaceHeader
|
||||
activeWorkspace={activeWorkspace}
|
||||
@@ -1299,7 +1294,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Top Navigation: Home, Search */}
|
||||
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
|
||||
{topNavItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
@@ -1312,7 +1306,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-3.5 flex flex-shrink-0 flex-col pb-2'>
|
||||
<div className='px-4 pb-1.5'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
|
||||
@@ -1330,7 +1323,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div
|
||||
ref={isCollapsed ? undefined : scrollContainerRef}
|
||||
className={cn(
|
||||
@@ -1338,7 +1330,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
!hasOverflowTop && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
@@ -1460,7 +1451,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div
|
||||
className='workflows-section relative mt-3.5 flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
@@ -1612,36 +1602,27 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 flex-col gap-0.5 border-t px-2 pt-[9px] pb-2 transition-colors duration-150',
|
||||
!hasOverflowBottom && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Help dropdown */}
|
||||
<DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<SidebarTooltip label='Help' enabled={showCollapsedTooltips}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
data-item-id='help'
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
|
||||
Help
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<button
|
||||
type='button'
|
||||
data-item-id='help'
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
|
||||
Help
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Help</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</SidebarTooltip>
|
||||
<DropdownMenuContent align='start' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem onSelect={handleOpenDocs}>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
@@ -1669,7 +1650,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Nav Item Context Menu */}
|
||||
<NavItemContextMenu
|
||||
isOpen={isNavContextMenuOpen}
|
||||
position={navContextMenuPosition}
|
||||
@@ -1679,7 +1659,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
onCopyLink={handleNavCopyLink}
|
||||
/>
|
||||
|
||||
{/* Task Context Menu */}
|
||||
<ContextMenu
|
||||
isOpen={isTaskContextMenuOpen}
|
||||
position={taskContextMenuPosition}
|
||||
@@ -1704,7 +1683,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
disableDelete={!canEdit}
|
||||
/>
|
||||
|
||||
{/* Task Delete Confirmation Modal */}
|
||||
<DeleteModal
|
||||
isOpen={isTaskDeleteModalOpen}
|
||||
onClose={handleCloseTaskDeleteModal}
|
||||
@@ -1735,7 +1713,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Universal Search Modal */}
|
||||
<SearchModal
|
||||
open={isSearchModalOpen}
|
||||
onOpenChange={setIsSearchModalOpen}
|
||||
@@ -1748,14 +1725,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
isOnWorkflowPage={!!workflowId}
|
||||
/>
|
||||
|
||||
{/* Footer Navigation Modals */}
|
||||
<HelpModal
|
||||
open={isHelpModalOpen}
|
||||
onOpenChange={setIsHelpModalOpen}
|
||||
workflowId={workflowId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
{/* Hidden file input for workspace import */}
|
||||
<input
|
||||
ref={workspaceFileInputRef}
|
||||
type='file'
|
||||
|
||||
@@ -10,17 +10,36 @@ function isDarkBackground(hexColor: string): boolean {
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
function getContrastTextColor(hexColor: string): string {
|
||||
return isDarkBackground(hexColor) ? '#ffffff' : '#000000'
|
||||
}
|
||||
|
||||
export function generateThemeCSS(): string {
|
||||
const cssVars: string[] = []
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR) {
|
||||
cssVars.push(`--brand: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
// Override brand-accent so Run/Deploy buttons and other accent-styled elements use the brand color
|
||||
cssVars.push(`--brand-accent: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
const primaryTextColor = getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR)
|
||||
cssVars.push(`--auth-primary-btn-text: ${primaryTextColor};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-text: ${primaryTextColor};`)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR) {
|
||||
cssVars.push(`--brand-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
|
||||
)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
|
||||
)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-text: ${getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR)};`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR) {
|
||||
@@ -32,7 +51,6 @@ export function generateThemeCSS(): string {
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
|
||||
// Add dark theme class when background is dark
|
||||
const isDark = isDarkBackground(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR)
|
||||
if (isDark) {
|
||||
cssVars.push(`--brand-is-dark: 1;`)
|
||||
|
||||
@@ -233,7 +233,9 @@ export function useDocumentChunks(
|
||||
documentId: string,
|
||||
page = 1,
|
||||
search = '',
|
||||
enabledFilter: 'all' | 'enabled' | 'disabled' = 'all'
|
||||
enabledFilter: 'all' | 'enabled' | 'disabled' = 'all',
|
||||
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled',
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -248,6 +250,8 @@ export function useDocumentChunks(
|
||||
offset,
|
||||
search: search || undefined,
|
||||
enabledFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(knowledgeBaseId && documentId),
|
||||
@@ -280,11 +284,13 @@ export function useDocumentChunks(
|
||||
offset,
|
||||
search: search || undefined,
|
||||
enabledFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
|
||||
})
|
||||
}, [knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient])
|
||||
}, [knowledgeBaseId, documentId, offset, search, enabledFilter, sortBy, sortOrder, queryClient])
|
||||
|
||||
const updateChunk = useCallback(
|
||||
(chunkId: string, updates: Partial<ChunkData>) => {
|
||||
@@ -295,6 +301,8 @@ export function useDocumentChunks(
|
||||
offset,
|
||||
search: search || undefined,
|
||||
enabledFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
})
|
||||
queryClient.setQueryData<KnowledgeChunksResponse>(
|
||||
knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
|
||||
@@ -309,7 +317,7 @@ export function useDocumentChunks(
|
||||
}
|
||||
)
|
||||
},
|
||||
[knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient]
|
||||
[knowledgeBaseId, documentId, offset, search, enabledFilter, sortBy, sortOrder, queryClient]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface KnowledgeChunksParams {
|
||||
enabledFilter?: 'all' | 'enabled' | 'disabled'
|
||||
limit?: number
|
||||
offset?: number
|
||||
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled'
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface KnowledgeChunksResponse {
|
||||
@@ -196,6 +198,8 @@ export async function fetchKnowledgeChunks(
|
||||
enabledFilter,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: KnowledgeChunksParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<KnowledgeChunksResponse> {
|
||||
@@ -206,6 +210,8 @@ export async function fetchKnowledgeChunks(
|
||||
}
|
||||
if (limit) params.set('limit', limit.toString())
|
||||
if (offset) params.set('offset', offset.toString())
|
||||
if (sortBy && sortBy !== 'chunkIndex') params.set('sortBy', sortBy)
|
||||
if (sortOrder && sortOrder !== 'asc') params.set('sortOrder', sortOrder)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks${params.toString() ? `?${params.toString()}` : ''}`,
|
||||
@@ -306,6 +312,8 @@ export const serializeChunkParams = (params: KnowledgeChunksParams) =>
|
||||
enabledFilter: params.enabledFilter ?? 'all',
|
||||
limit: params.limit ?? 50,
|
||||
offset: params.offset ?? 0,
|
||||
sortBy: params.sortBy ?? 'chunkIndex',
|
||||
sortOrder: params.sortOrder ?? 'asc',
|
||||
})
|
||||
|
||||
export function useKnowledgeChunksQuery(
|
||||
|
||||
@@ -5,6 +5,10 @@ const STICK_THRESHOLD = 30
|
||||
/** User must scroll back to within this distance to re-engage auto-scroll. */
|
||||
const REATTACH_THRESHOLD = 5
|
||||
|
||||
interface UseAutoScrollOptions {
|
||||
scrollOnMount?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages sticky auto-scroll for a streaming chat container.
|
||||
*
|
||||
@@ -16,7 +20,10 @@ const REATTACH_THRESHOLD = 5
|
||||
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
|
||||
* for imperative use after layout-changing events like panel expansion.
|
||||
*/
|
||||
export function useAutoScroll(isStreaming: boolean) {
|
||||
export function useAutoScroll(
|
||||
isStreaming: boolean,
|
||||
{ scrollOnMount = false }: UseAutoScrollOptions = {}
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const stickyRef = useRef(true)
|
||||
const userDetachedRef = useRef(false)
|
||||
@@ -24,6 +31,7 @@ export function useAutoScroll(isStreaming: boolean) {
|
||||
const prevScrollHeightRef = useRef(0)
|
||||
const touchStartYRef = useRef(0)
|
||||
const rafIdRef = useRef(0)
|
||||
const scrollOnMountRef = useRef(scrollOnMount)
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
@@ -33,7 +41,7 @@ export function useAutoScroll(isStreaming: boolean) {
|
||||
|
||||
const callbackRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
120
apps/sim/lib/analytics/profound.ts
Normal file
120
apps/sim/lib/analytics/profound.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Profound Analytics - Custom log integration
|
||||
*
|
||||
* Buffers HTTP request logs in memory and flushes them in batches to Profound's API.
|
||||
* Runs in Node.js (proxy.ts on ECS), so module-level state persists across requests.
|
||||
* @see https://docs.tryprofound.com/agent-analytics/custom
|
||||
*/
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
|
||||
const logger = createLogger('ProfoundAnalytics')
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000
|
||||
const MAX_BATCH_SIZE = 500
|
||||
|
||||
interface ProfoundLogEntry {
|
||||
timestamp: string
|
||||
method: string
|
||||
host: string
|
||||
path: string
|
||||
status_code: number
|
||||
ip: string
|
||||
user_agent: string
|
||||
query_params?: Record<string, string>
|
||||
referer?: string
|
||||
}
|
||||
|
||||
let buffer: ProfoundLogEntry[] = []
|
||||
let flushTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/**
|
||||
* Returns true if Profound analytics is configured.
|
||||
*/
|
||||
export function isProfoundEnabled(): boolean {
|
||||
return isHosted && Boolean(env.PROFOUND_API_KEY) && Boolean(env.PROFOUND_ENDPOINT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes buffered log entries to Profound's API.
|
||||
*/
|
||||
async function flush(): Promise<void> {
|
||||
if (buffer.length === 0) return
|
||||
|
||||
const apiKey = env.PROFOUND_API_KEY
|
||||
if (!apiKey) {
|
||||
buffer = []
|
||||
return
|
||||
}
|
||||
|
||||
const endpoint = env.PROFOUND_ENDPOINT
|
||||
if (!endpoint) {
|
||||
buffer = []
|
||||
return
|
||||
}
|
||||
const entries = buffer.splice(0, MAX_BATCH_SIZE)
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(entries),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Profound API returned ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to flush logs to Profound', error)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFlushTimer(): void {
|
||||
if (flushTimer) return
|
||||
flushTimer = setInterval(() => {
|
||||
flush().catch(() => {})
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
flushTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a request log entry for the next batch flush to Profound.
|
||||
*/
|
||||
export function sendToProfound(request: Request, statusCode: number): void {
|
||||
if (!isProfoundEnabled()) return
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const queryParams: Record<string, string> = {}
|
||||
url.searchParams.forEach((value, key) => {
|
||||
queryParams[key] = value
|
||||
})
|
||||
|
||||
buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
method: request.method,
|
||||
host: url.hostname,
|
||||
path: url.pathname,
|
||||
status_code: statusCode,
|
||||
ip:
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'0.0.0.0',
|
||||
user_agent: request.headers.get('user-agent') || '',
|
||||
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
|
||||
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
|
||||
})
|
||||
|
||||
ensureFlushTimer()
|
||||
|
||||
if (buffer.length >= MAX_BATCH_SIZE) {
|
||||
flush().catch(() => {})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to enqueue log entry', error)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ConnectionOptions } from 'bullmq'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
|
||||
export function isBullMQEnabled(): boolean {
|
||||
return Boolean(env.REDIS_URL)
|
||||
return isTruthy(env.CONCURRENCY_CONTROL_ENABLED) && Boolean(env.REDIS_URL)
|
||||
}
|
||||
|
||||
export function getBullMQConnectionOptions(): ConnectionOptions {
|
||||
|
||||
@@ -135,6 +135,8 @@ export const env = createEnv({
|
||||
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
|
||||
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
|
||||
DRIZZLE_ODS_API_KEY: z.string().min(1).optional(), // OneDollarStats API key for analytics tracking
|
||||
PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key
|
||||
PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint
|
||||
|
||||
// External Services
|
||||
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
|
||||
@@ -184,6 +186,7 @@ export const env = createEnv({
|
||||
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
|
||||
|
||||
// Admission & Burst Protection
|
||||
CONCURRENCY_CONTROL_ENABLED: z.string().optional().default('false'), // Set to 'true' to enable BullMQ-based concurrency control (default: inline execution)
|
||||
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
|
||||
DISPATCH_MAX_QUEUE_PER_WORKSPACE: z.string().optional().default('1000'), // Max queued dispatch jobs per workspace
|
||||
DISPATCH_MAX_QUEUE_GLOBAL: z.string().optional().default('50000'), // Max queued dispatch jobs globally
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { document, embedding, knowledgeBase } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type {
|
||||
BatchOperationResult,
|
||||
ChunkData,
|
||||
@@ -23,24 +23,27 @@ export async function queryChunks(
|
||||
filters: ChunkFilters,
|
||||
requestId: string
|
||||
): Promise<ChunkQueryResult> {
|
||||
const { search, enabled = 'all', limit = 50, offset = 0 } = filters
|
||||
const {
|
||||
search,
|
||||
enabled = 'all',
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sortBy = 'chunkIndex',
|
||||
sortOrder = 'asc',
|
||||
} = filters
|
||||
|
||||
// Build query conditions
|
||||
const conditions = [eq(embedding.documentId, documentId)]
|
||||
|
||||
// Add enabled filter
|
||||
if (enabled === 'true') {
|
||||
conditions.push(eq(embedding.enabled, true))
|
||||
} else if (enabled === 'false') {
|
||||
conditions.push(eq(embedding.enabled, false))
|
||||
}
|
||||
|
||||
// Add search filter
|
||||
if (search) {
|
||||
conditions.push(ilike(embedding.content, `%${search}%`))
|
||||
}
|
||||
|
||||
// Fetch chunks
|
||||
const chunks = await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
@@ -63,11 +66,20 @@ export async function queryChunks(
|
||||
})
|
||||
.from(embedding)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(embedding.chunkIndex))
|
||||
.orderBy(
|
||||
(() => {
|
||||
const col =
|
||||
sortBy === 'tokenCount'
|
||||
? embedding.tokenCount
|
||||
: sortBy === 'enabled'
|
||||
? embedding.enabled
|
||||
: embedding.chunkIndex
|
||||
return sortOrder === 'desc' ? desc(col) : asc(col)
|
||||
})()
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(embedding)
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface ChunkFilters {
|
||||
enabled?: 'true' | 'false' | 'all'
|
||||
limit?: number
|
||||
offset?: number
|
||||
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled'
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface ChunkData {
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
"load:workflow:baseline": "BASE_URL=${BASE_URL:-http://localhost:3000} WARMUP_DURATION=${WARMUP_DURATION:-10} WARMUP_RATE=${WARMUP_RATE:-2} PEAK_RATE=${PEAK_RATE:-8} HOLD_DURATION=${HOLD_DURATION:-20} bunx artillery run scripts/load/workflow-concurrency.yml",
|
||||
"load:workflow:waves": "BASE_URL=${BASE_URL:-http://localhost:3000} WAVE_ONE_DURATION=${WAVE_ONE_DURATION:-10} WAVE_ONE_RATE=${WAVE_ONE_RATE:-6} QUIET_DURATION=${QUIET_DURATION:-5} WAVE_TWO_DURATION=${WAVE_TWO_DURATION:-15} WAVE_TWO_RATE=${WAVE_TWO_RATE:-8} WAVE_THREE_DURATION=${WAVE_THREE_DURATION:-20} WAVE_THREE_RATE=${WAVE_THREE_RATE:-10} bunx artillery run scripts/load/workflow-waves.yml",
|
||||
"load:workflow:isolation": "BASE_URL=${BASE_URL:-http://localhost:3000} ISOLATION_DURATION=${ISOLATION_DURATION:-30} TOTAL_RATE=${TOTAL_RATE:-9} WORKSPACE_A_WEIGHT=${WORKSPACE_A_WEIGHT:-8} WORKSPACE_B_WEIGHT=${WORKSPACE_B_WEIGHT:-1} bunx artillery run scripts/load/workflow-isolation.yml",
|
||||
"build": "bun run build:pptx-worker && bun run build:worker && next build",
|
||||
"build": "bun run build:pptx-worker && next build",
|
||||
"build:pptx-worker": "bun build ./lib/execution/pptx-worker.cjs --target=node --format=cjs --outfile ./dist/pptx-worker.cjs",
|
||||
"build:worker": "bun build ./worker/index.ts --target=node --format=esm --splitting --outdir ./dist/worker --external isolated-vm",
|
||||
"start": "next start",
|
||||
"worker": "NODE_ENV=production bun run worker/index.ts",
|
||||
"prepare": "cd ../.. && bun husky",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sendToProfound } from './lib/analytics/profound'
|
||||
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
|
||||
import { generateRuntimeCSP } from './lib/core/security/csp'
|
||||
|
||||
@@ -144,47 +145,47 @@ export async function proxy(request: NextRequest) {
|
||||
const hasActiveSession = isAuthDisabled || !!sessionCookie
|
||||
|
||||
const redirect = handleRootPathRedirects(request, hasActiveSession)
|
||||
if (redirect) return redirect
|
||||
if (redirect) return track(request, redirect)
|
||||
|
||||
if (url.pathname === '/login' || url.pathname === '/signup') {
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
return track(request, NextResponse.redirect(new URL('/workspace', request.url)))
|
||||
}
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
||||
return response
|
||||
return track(request, response)
|
||||
}
|
||||
|
||||
// Chat pages are publicly accessible embeds — CSP is set in next.config.ts headers
|
||||
if (url.pathname.startsWith('/chat/')) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
// Allow public access to template pages for SEO
|
||||
if (url.pathname.startsWith('/templates')) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
// Allow public access to workspace template pages - they handle their own redirects
|
||||
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
return track(request, NextResponse.redirect(new URL('/login', request.url)))
|
||||
}
|
||||
return NextResponse.next()
|
||||
return track(request, NextResponse.next())
|
||||
}
|
||||
|
||||
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
|
||||
if (invitationRedirect) return invitationRedirect
|
||||
if (invitationRedirect) return track(request, invitationRedirect)
|
||||
|
||||
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
|
||||
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
|
||||
if (workspaceInvitationRedirect) return track(request, workspaceInvitationRedirect)
|
||||
|
||||
const securityBlock = handleSecurityFiltering(request)
|
||||
if (securityBlock) return securityBlock
|
||||
if (securityBlock) return track(request, securityBlock)
|
||||
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('Vary', 'User-Agent')
|
||||
@@ -193,6 +194,14 @@ export async function proxy(request: NextRequest) {
|
||||
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
|
||||
}
|
||||
|
||||
return track(request, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
|
||||
*/
|
||||
function track(request: NextRequest, response: NextResponse): NextResponse {
|
||||
sendToProfound(request, response.status)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/app.Dockerfile
|
||||
command: ['bun', 'apps/sim/dist/worker/index.js']
|
||||
command: ['bun', 'apps/sim/worker/index.ts']
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
|
||||
sim-worker:
|
||||
image: ghcr.io/simstudioai/simstudio:latest
|
||||
command: ['bun', 'apps/sim/dist/worker/index.js']
|
||||
command: ['bun', 'apps/sim/worker/index.ts']
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -105,6 +105,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/public ./apps/sim/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/static ./apps/sim/.next/static
|
||||
|
||||
# Copy the full dependency tree and app source so the BullMQ worker can run from source.
|
||||
# The standalone server continues to use server.js; the worker uses bun on worker/index.ts.
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim ./apps/sim
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
|
||||
|
||||
# Copy isolated-vm native module (compiled for Node.js in deps stage)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/isolated-vm ./node_modules/isolated-vm
|
||||
|
||||
@@ -114,9 +120,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-v
|
||||
# Copy the bundled PPTX worker artifact
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/dist/pptx-worker.cjs ./apps/sim/dist/pptx-worker.cjs
|
||||
|
||||
# Copy the bundled BullMQ worker (self-contained ESM bundle, only isolated-vm is external)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/dist/worker ./apps/sim/dist/worker
|
||||
|
||||
# Guardrails setup with pip caching
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/validate_pii.py ./apps/sim/lib/guardrails/validate_pii.py
|
||||
|
||||
@@ -37,7 +37,7 @@ spec:
|
||||
- name: worker
|
||||
image: {{ include "sim.image" (dict "context" . "image" .Values.worker.image) }}
|
||||
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
|
||||
command: ["bun", "apps/sim/dist/worker/index.js"]
|
||||
command: ["bun", "apps/sim/worker/index.ts"]
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: {{ .Values.worker.healthPort }}
|
||||
|
||||
Reference in New Issue
Block a user