improvement: tables, favicon

This commit is contained in:
Emir Karabeg
2026-03-08 19:20:53 -07:00
parent 1dbfaa4d23
commit 627eaaf343
54 changed files with 1368 additions and 802 deletions

View File

@@ -0,0 +1,37 @@
'use client'
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
}
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
useEffect(() => {
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
}
}, [])
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
}

View File

@@ -1,42 +1,10 @@
'use client'
import type { Metadata } from 'next'
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
// Helper to detect if a color is dark
function isColorDark(hexColor: string): boolean {
const hex = hexColor.replace('#', '')
const r = Number.parseInt(hex.substr(0, 2), 16)
const g = Number.parseInt(hex.substr(2, 2), 16)
const b = Number.parseInt(hex.substr(4, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Check if brand background is dark and add class accordingly
const rootStyle = getComputedStyle(document.documentElement)
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
if (brandBackground && isColorDark(brandBackground)) {
document.body.classList.add('auth-dark-bg')
} else {
document.body.classList.remove('auth-dark-bg')
}
}, [])
return (
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
{/* Header - Nav handles all conditional logic */}
<Nav hideAuthButtons={true} variant='auth' />
{/* Content */}
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
return <AuthLayoutClient>{children}</AuthLayoutClient>
}

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
export const metadata: Metadata = {
title: 'Log In',
}
export const dynamic = 'force-dynamic'
export default async function LoginPage() {

View File

@@ -1,117 +1,8 @@
'use client'
import type { Metadata } from 'next'
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const handleResetPassword = async (password: string) => {
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
newPassword: password,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to reset password',
})
} finally {
setIsSubmitting(false)
}
}
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter a new password for your account
</p>
</div>
<div className={`${inter.className} mt-8`}>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</Link>
</div>
</>
)
export const metadata: Metadata = {
title: 'Reset Password',
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}
export default ResetPasswordPage

View File

@@ -0,0 +1,117 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const handleResetPassword = async (password: string) => {
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
newPassword: password,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to reset password',
})
} finally {
setIsSubmitting(false)
}
}
return (
<>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Reset your password
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Enter a new password for your account
</p>
</div>
<div className={`${inter.className} mt-8`}>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</div>
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
<Link
href='/login'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Back to login
</Link>
</div>
</>
)
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
title: 'Sign Up',
}
export const dynamic = 'force-dynamic'
export default async function SignupPage() {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
title: 'Single Sign-On',
}
export const dynamic = 'force-dynamic'
export default async function SSOPage() {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
export const metadata: Metadata = {
title: 'Verify Email',
}
export const dynamic = 'force-dynamic'
export default function VerifyPage() {

View File

@@ -4,8 +4,8 @@ export const metadata: Metadata = {
metadataBase: new URL('https://sim.ai'),
manifest: '/manifest.json',
icons: {
icon: '/favicon.ico',
apple: '/apple-icon.png',
icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }],
apple: '/favicon/apple-touch-icon.png',
},
other: {
'msapplication-TileColor': '#000000',

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
return { title: author?.name ?? 'Author' }
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)

View File

@@ -1,8 +1,14 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
export const metadata: Metadata = {
title: 'Studio',
description: 'Announcements, insights, and guides from the Sim team.',
}
export const revalidate = 3600
export default async function StudioIndex({

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllTags } from '@/lib/blog/registry'
export const metadata: Metadata = {
title: 'Tags',
}
export default async function TagsIndex() {
const tags = await getAllTags()
return (

View File

@@ -0,0 +1,77 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { addTableColumn } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
const logger = createLogger('TableColumnsAPI')
const CreateColumnSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z.object({
name: z.string().min(1, 'Column name is required'),
type: z.enum(['string', 'number', 'boolean', 'date', 'json']),
required: z.boolean().optional(),
unique: z.boolean().optional(),
}),
})
interface ColumnsRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/table/[tableId]/columns - Adds a column to the table schema. */
export async function POST(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized column creation attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const validated = CreateColumnSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await addTableColumn(tableId, validated.column, requestId)
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
}
logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
}

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
import ChatClient from '@/app/chat/[identifier]/chat'
export const metadata: Metadata = {
title: 'Chat',
}
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params
return <ChatClient identifier={identifier} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
import Form from '@/app/form/[identifier]/form'
export const metadata: Metadata = {
title: 'Form',
}
export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params
return <Form identifier={identifier} />

View File

@@ -1,3 +1,9 @@
import type { Metadata } from 'next'
import Invite from '@/app/invite/[id]/invite'
export const metadata: Metadata = {
title: 'Invite',
robots: { index: false },
}
export default Invite

View File

@@ -1,6 +1,12 @@
import type { Metadata } from 'next'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client'
export const metadata: Metadata = {
title: 'Resume Execution',
robots: { index: false },
}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

View File

@@ -1,11 +1,18 @@
import { db } from '@sim/db'
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import type { Template } from '@/app/templates/templates'
import Templates from '@/app/templates/templates'
export const metadata: Metadata = {
title: 'Templates',
description:
'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
}
/**
* Public templates list page.
* Redirects authenticated users to their workspace-scoped templates page.

View File

@@ -1,3 +1,9 @@
import type { Metadata } from 'next'
import Unsubscribe from '@/app/unsubscribe/unsubscribe'
export const metadata: Metadata = {
title: 'Unsubscribe',
robots: { index: false },
}
export default Unsubscribe

View File

@@ -32,7 +32,7 @@ export function ResourceOptionsBar({
<div className='flex items-center justify-between'>
{search && (
<div className='flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search.value}
@@ -45,13 +45,13 @@ export function ResourceOptionsBar({
<div className='flex items-center gap-[6px]'>
{onFilter && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onFilter}>
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
<ListFilter className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</Button>
)}
{onSort && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</Button>
)}

View File

@@ -137,7 +137,7 @@ export function Resource({
>
{col.header}
{sort.column === col.id && (
<SortIcon className='ml-[4px] h-[12px] w-[12px]' />
<SortIcon className='ml-[4px] h-[12px] w-[12px] text-[var(--text-icon)]' />
)}
</Button>
</th>
@@ -210,7 +210,7 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{cell.icon && <span className='flex-shrink-0 text-[var(--text-subtle)]'>{cell.icon}</span>}
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
<span className='truncate'>{cell.label}</span>
</span>
)

View File

@@ -1,9 +1,15 @@
import type { Metadata } from 'next'
import { redirect, unstable_rethrow } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer'
export const metadata: Metadata = {
title: 'File Viewer',
robots: { index: false },
}
interface FileViewerPageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,9 +1,15 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from './files'
export const metadata: Metadata = {
title: 'Files',
robots: { index: false },
}
interface FilesPageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,8 +1,13 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Home } from './home'
export const metadata: Metadata = {
title: 'Home',
}
interface HomePageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
interface DocumentPageProps {
@@ -11,6 +12,13 @@ interface DocumentPageProps {
}>
}
export async function generateMetadata({ searchParams }: DocumentPageProps): Promise<Metadata> {
const { docName, kbName } = await searchParams
const title = docName || 'Document'
const parentName = kbName || 'Knowledge Base'
return { title: `${title}${parentName}` }
}
export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) {
const { id, documentId } = await params
const { kbName, docName } = await searchParams

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
interface PageProps {
@@ -9,6 +10,11 @@ interface PageProps {
}>
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const { kbName } = await searchParams
return { title: kbName || 'Knowledge Base' }
}
export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
const { id } = await params
const { kbName } = await searchParams

View File

@@ -100,10 +100,6 @@ export function Knowledge() {
setIsCreateModalOpen(true)
}, [])
const handleSort = useCallback(() => {}, [])
const handleFilter = useCallback(() => {}, [])
const handleUpdateKnowledgeBase = useCallback(
async (id: string, name: string, description: string) => {
await updateKnowledgeBaseMutation({
@@ -208,8 +204,8 @@ export function Knowledge() {
placeholder: 'Search knowledge bases...',
}}
defaultSort='created'
onSort={handleSort}
onFilter={handleFilter}
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}

View File

@@ -1,9 +1,14 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Knowledge } from './knowledge'
export const metadata: Metadata = {
title: 'Knowledge Base',
}
interface KnowledgePageProps {
params: Promise<{
workspaceId: string

View File

@@ -12,7 +12,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full bg-[#F4F4F4] dark:bg-[var(--surface-1)]'>
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />

View File

@@ -1,3 +1,8 @@
import type { Metadata } from 'next'
import Logs from '@/app/workspace/[workspaceId]/logs/logs'
export const metadata: Metadata = {
title: 'Logs',
}
export default Logs

View File

@@ -1,8 +1,13 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Schedules } from './schedules'
export const metadata: Metadata = {
title: 'Schedules',
}
interface SchedulesPageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,12 +1,40 @@
'use client'
import { useParams } from 'next/navigation'
import type { Metadata } from 'next'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { SettingsPage } from './settings'
export default function SettingsSectionPage() {
const params = useParams()
const section = params.section as SettingsSection
const SECTION_TITLES: Record<string, string> = {
general: 'General',
credentials: 'Secrets',
'template-profile': 'Template Profile',
'access-control': 'Access Control',
apikeys: 'Sim Keys',
byok: 'BYOK',
subscription: 'Subscription',
team: 'Team',
sso: 'Single Sign-On',
copilot: 'Copilot Keys',
mcp: 'MCP Tools',
'custom-tools': 'Custom Tools',
skills: 'Skills',
'workflow-mcp-servers': 'MCP Servers',
'credential-sets': 'Email Polling',
debug: 'Debug',
} as const
return <SettingsPage section={section} />
export async function generateMetadata({
params,
}: {
params: Promise<{ section: string }>
}): Promise<Metadata> {
const { section } = await params
return { title: SECTION_TITLES[section] ?? 'Settings' }
}
export default async function SettingsSectionPage({
params,
}: {
params: Promise<{ workspaceId: string; section: string }>
}) {
const { section } = await params
return <SettingsPage section={section as SettingsSection} />
}

View File

@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Button, Checkbox, Skeleton } from '@/components/emcn'
import {
Calendar as CalendarIcon,
Plus,
Table as TableIcon,
TypeBoolean,
TypeJson,
@@ -14,17 +15,46 @@ import {
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
import { STRING_TRUNCATE_LENGTH } from '../../constants'
import { useAddTableColumn, useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
import type { CellViewerData, EditingCell, QueryOptions } from '../../types'
import type { EditingCell, QueryOptions } from '../../types'
import { cleanCellValue, formatValueForInput } from '../../utils'
import { CellViewerModal } from '../cell-viewer-modal'
import { ContextMenu } from '../context-menu'
import { RowModal } from '../row-modal'
import { SchemaModal } from '../schema-modal'
type SelectedCell =
| { kind: 'data'; rowId: string; columnName: string }
| { kind: 'placeholder'; index: number; columnName: string }
interface PendingPlaceholder {
rowId: string | null
data: Record<string, unknown>
}
const EMPTY_COLUMNS: never[] = []
const PLACEHOLDER_ROW_COUNT = 50
const COL_WIDTH = 160
const CHECKBOX_COL_WIDTH = 40
const ADD_COL_WIDTH = 120
const CELL = 'border-[var(--border)] border-r border-b px-[8px] py-[7px] align-middle'
const CELL_CHECKBOX = 'border-[var(--border)] border-r border-b px-[12px] py-[7px] align-middle'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-white px-[8px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
const CELL_HEADER_CHECKBOX =
'border-[var(--border)] border-r border-b bg-white px-[12px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
const CELL_CONTENT = 'relative min-h-[20px] min-w-0 overflow-hidden truncate text-[13px]'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px border-2 border-[var(--brand-tertiary-2)]'
const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
string: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
date: CalendarIcon,
json: TypeJson,
}
export function Table() {
const params = useParams()
@@ -44,15 +74,16 @@ export function Table() {
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null)
const [pendingPlaceholders, setPendingPlaceholders] = useState<
Record<number, PendingPlaceholder>
>({})
const [editingEmptyCell, setEditingEmptyCell] = useState<{
rowIndex: number
columnName: string
} | null>(null)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
workspaceId,
tableId,
@@ -70,17 +101,34 @@ export function Table() {
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
[tableData?.schema?.columns]
)
const pendingRowIds = useMemo(() => {
const ids = new Set<string>()
for (const pending of Object.values(pendingPlaceholders)) {
if (pending.rowId) ids.add(pending.rowId)
}
return ids
}, [pendingPlaceholders])
const visibleRows = useMemo(
() => rows.filter((r) => !pendingRowIds.has(r.id)),
[rows, pendingRowIds]
)
const tableWidth = CHECKBOX_COL_WIDTH + columns.length * COL_WIDTH + ADD_COL_WIDTH
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
const isAllSelected = rows.length > 0 && selectedCount === rows.length
const isAllSelected = visibleRows.length > 0 && selectedCount === visibleRows.length
const columnsRef = useRef(columns)
const rowsRef = useRef(rows)
const pendingPlaceholdersRef = useRef(pendingPlaceholders)
useEffect(() => {
columnsRef.current = columns
@@ -88,6 +136,9 @@ export function Table() {
useEffect(() => {
rowsRef.current = rows
}, [rows])
useEffect(() => {
pendingPlaceholdersRef.current = pendingPlaceholders
}, [pendingPlaceholders])
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
@@ -97,10 +148,6 @@ export function Table() {
setShowAddModal(true)
}, [])
const handleSort = useCallback(() => {}, [])
const handleFilter = useCallback(() => {}, [])
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
@@ -127,30 +174,17 @@ export function Table() {
[baseHandleRowContextMenu]
)
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
const handleSelectCell = useCallback((rowId: string, columnName: string) => {
setSelectedCell({ kind: 'data', rowId, columnName })
}, [])
const handleCellClick = useCallback(
(columnName: string, value: unknown, type: CellViewerData['type']) => {
setCellViewer({ columnName, value, type })
},
[]
)
const handleSelectPlaceholderCell = useCallback((index: number, columnName: string) => {
setSelectedCell({ kind: 'placeholder', index, columnName })
}, [])
const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => {
setSelectedCell({ kind: 'data', rowId, columnName })
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
@@ -160,7 +194,13 @@ export function Table() {
return
}
if (column.type === 'boolean') return
if (column.type === 'boolean') {
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) {
mutateRef.current({ rowId, data: { [columnName]: !row.data[columnName] } })
}
return
}
setEditingCell({ rowId, columnName })
}, [])
@@ -170,6 +210,60 @@ export function Table() {
mutateRef.current = updateRowMutation.mutate
}, [updateRowMutation.mutate])
const selectedCellRef = useRef(selectedCell)
useEffect(() => {
selectedCellRef.current = selectedCell
}, [selectedCell])
const editingCellRef = useRef(editingCell)
useEffect(() => {
editingCellRef.current = editingCell
}, [editingCell])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const sel = selectedCellRef.current
if (!sel || sel.kind !== 'data' || editingCellRef.current) return
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
mutateRef.current({ rowId: sel.rowId, data: { [sel.columnName]: null } })
}
if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
e.preventDefault()
const row = rowsRef.current.find((r) => r.id === sel.rowId)
if (row) {
const value = row.data[sel.columnName]
let text = ''
if (value !== null && value !== undefined) {
text = typeof value === 'object' ? JSON.stringify(value) : String(value)
}
navigator.clipboard.writeText(text)
}
}
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
const current = selectedCellRef.current
if (!current || current.kind !== 'data') return
const column = columnsRef.current.find((c) => c.name === current.columnName)
if (!column) return
try {
const cleaned = cleanCellValue(text, column)
mutateRef.current({ rowId: current.rowId, data: { [current.columnName]: cleaned } })
} catch {
// ignore invalid paste values
}
})
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
const handleInlineSave = useCallback((rowId: string, columnName: string, value: unknown) => {
setEditingCell(null)
@@ -187,13 +281,6 @@ export function Table() {
setEditingCell(null)
}, [])
const handleBooleanToggle = useCallback(
(rowId: string, columnName: string, currentValue: boolean) => {
mutateRef.current({ rowId, data: { [columnName]: !currentValue } })
},
[]
)
const handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'json' || column.type === 'boolean') return
@@ -205,16 +292,65 @@ export function Table() {
createRef.current = createRowMutation.mutate
}, [createRowMutation.mutate])
const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => {
const handleEmptyRowSave = useCallback((rowIndex: number, columnName: string, value: unknown) => {
setEditingEmptyCell(null)
if (value === null || value === undefined || value === '') return
createRef.current({ [columnName]: value })
const existing = pendingPlaceholdersRef.current[rowIndex]
const updatedData = { ...(existing?.data || {}), [columnName]: value }
if (existing?.rowId) {
setPendingPlaceholders((prev) => ({
...prev,
[rowIndex]: { ...prev[rowIndex], data: updatedData },
}))
mutateRef.current({ rowId: existing.rowId, data: { [columnName]: value } })
} else {
setPendingPlaceholders((prev) => ({
...prev,
[rowIndex]: { rowId: null, data: updatedData },
}))
createRef.current(updatedData, {
onSuccess: (response: Record<string, unknown>) => {
const data = response?.data as Record<string, unknown> | undefined
const row = data?.row as Record<string, unknown> | undefined
const newRowId = row?.id as string | undefined
if (newRowId) {
setPendingPlaceholders((prev) => {
if (!prev[rowIndex]) return prev
return {
...prev,
[rowIndex]: { ...prev[rowIndex], rowId: newRowId },
}
})
}
},
onError: () => {
setPendingPlaceholders((prev) => {
const next = { ...prev }
delete next[rowIndex]
return next
})
},
})
}
}, [])
const handleEmptyRowCancel = useCallback(() => {
setEditingEmptyCell(null)
}, [])
const handleAddColumn = useCallback(() => {
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
let name = 'untitled'
let i = 2
while (existing.includes(name.toLowerCase())) {
name = `untitled_${i}`
i++
}
addColumnMutation.mutate({ name, type: 'string' })
}, [addColumnMutation])
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
@@ -238,34 +374,42 @@ export function Table() {
breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]}
/>
<ResourceOptionsBar onSort={handleSort} onFilter={handleFilter} />
<ResourceOptionsBar onSort={() => {}} onFilter={() => {}} />
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='border-collapse text-[13px]'>
<colgroup>
<col className='w-[40px]' />
{columns.map((col) => (
<col key={col.name} className='w-[160px]' />
))}
</colgroup>
<thead className='sticky top-0 z-10 bg-white shadow-[inset_0_-1px_0_var(--border)] dark:bg-[var(--bg)]'>
<table
className='table-fixed border-separate border-spacing-0 text-[13px]'
style={{ width: `${tableWidth}px` }}
>
<TableColGroup columns={columns} />
<thead className='sticky top-0 z-10'>
<tr>
<th className='border-[var(--border)] border-r py-[10px] pr-[12px] pl-[24px] text-left align-middle'>
<th className={CELL_HEADER_CHECKBOX}>
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</th>
{columns.map((column) => (
<th
key={column.name}
className='border-[var(--border)] border-r px-[24px] py-[10px] text-left align-middle'
>
<div className='flex items-center gap-[8px]'>
<th key={column.name} className={CELL_HEADER}>
<div className='flex min-w-0 items-center gap-[8px]'>
<ColumnTypeIcon type={column.type} />
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
<span className='min-w-0 truncate font-medium text-[13px] text-[var(--text-primary)]'>
{column.name}
</span>
</div>
</th>
))}
<th className={CELL_HEADER}>
<button
type='button'
className='flex cursor-pointer items-center gap-[8px]'
onClick={handleAddColumn}
disabled={addColumnMutation.isPending}
>
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Add column
</span>
</button>
</th>
</tr>
</thead>
<tbody>
@@ -273,7 +417,7 @@ export function Table() {
<TableSkeleton columns={columns} />
) : (
<>
{rows.map((row) => (
{visibleRows.map((row) => (
<DataRow
key={row.id}
row={row}
@@ -282,21 +426,28 @@ export function Table() {
editingColumnName={
editingCell?.rowId === row.id ? editingCell.columnName : null
}
onCellClick={handleCellClick}
selectedColumnName={
selectedCell?.kind === 'data' && selectedCell.rowId === row.id
? selectedCell.columnName
: null
}
onDoubleClick={handleCellDoubleClick}
onSave={handleInlineSave}
onCancel={handleInlineCancel}
onBooleanToggle={handleBooleanToggle}
onContextMenu={handleRowContextMenu}
onSelectRow={handleSelectRow}
onSelectCell={handleSelectCell}
/>
))}
<PlaceholderRows
columns={columns}
editingEmptyCell={editingEmptyCell}
pendingPlaceholders={pendingPlaceholders}
selectedCell={selectedCell}
onDoubleClick={handleEmptyRowDoubleClick}
onSave={handleEmptyRowSave}
onCancel={handleEmptyRowCancel}
onSelectCell={handleSelectPlaceholderCell}
/>
</>
)}
@@ -318,9 +469,7 @@ export function Table() {
isOpen={true}
onClose={() => setShowAddModal(false)}
table={tableData}
onSuccess={() => {
setShowAddModal(false)
}}
onSuccess={() => setShowAddModal(false)}
/>
)}
@@ -331,9 +480,7 @@ export function Table() {
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => {
setEditingRow(null)
}}
onSuccess={() => setEditingRow(null)}
/>
)}
@@ -358,13 +505,6 @@ export function Table() {
tableName={tableData.name}
/>
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
<ContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
@@ -375,60 +515,77 @@ export function Table() {
)
}
function TableColGroup({ columns }: { columns: ColumnDefinition[] }) {
return (
<colgroup>
<col style={{ width: CHECKBOX_COL_WIDTH }} />
{columns.map((col) => (
<col key={col.name} style={{ width: COL_WIDTH }} />
))}
<col style={{ width: ADD_COL_WIDTH }} />
</colgroup>
)
}
function SelectionOverlay() {
return <div className={SELECTION_OVERLAY} />
}
const DataRow = React.memo(function DataRow({
row,
columns,
isSelected,
editingColumnName,
onCellClick,
selectedColumnName,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
onContextMenu,
onSelectRow,
onSelectCell,
}: {
row: TableRowType
columns: ColumnDefinition[]
isSelected: boolean
editingColumnName: string | null
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
selectedColumnName: string | null
onDoubleClick: (rowId: string, columnName: string) => void
onSave: (rowId: string, columnName: string, value: unknown) => void
onCancel: () => void
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onSelectRow: (rowId: string) => void
onSelectCell: (rowId: string, columnName: string) => void
}) {
return (
<tr
className={cn('group', isSelected && 'bg-[var(--surface-5)]')}
onContextMenu={(e) => onContextMenu(e, row)}
>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
<td className={CELL_CHECKBOX}>
<Checkbox size='sm' checked={isSelected} onCheckedChange={() => onSelectRow(row.id)} />
</td>
{columns.map((column) => (
<td
key={column.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
>
<div className='max-w-[300px] truncate text-[13px]'>
<CellContent
value={row.data[column.name]}
column={column}
isEditing={editingColumnName === column.name}
onCellClick={onCellClick}
onDoubleClick={() => onDoubleClick(row.id, column.name)}
onSave={(value) => onSave(row.id, column.name, value)}
onCancel={onCancel}
onBooleanToggle={() =>
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
}
/>
</div>
</td>
))}
{columns.map((column) => {
const isColumnSelected = selectedColumnName === column.name
return (
<td
key={column.name}
className={cn(CELL, isColumnSelected && 'relative z-[11]')}
onClick={() => onSelectCell(row.id, column.name)}
onDoubleClick={() => onDoubleClick(row.id, column.name)}
>
{isColumnSelected && <SelectionOverlay />}
<div className={CELL_CONTENT}>
<CellContent
value={row.data[column.name]}
column={column}
isEditing={editingColumnName === column.name}
onSave={(value) => onSave(row.id, column.name, value)}
onCancel={onCancel}
/>
</div>
</td>
)
})}
</tr>
)
})
@@ -437,20 +594,14 @@ function CellContent({
value,
column,
isEditing,
onCellClick,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
}: {
value: unknown
column: ColumnDefinition
isEditing: boolean
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: () => void
onSave: (value: unknown) => void
onCancel: () => void
onBooleanToggle: () => void
}) {
if (isEditing) {
return <InlineEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
@@ -461,75 +612,25 @@ function CellContent({
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<button
type='button'
className='cursor-pointer select-none'
onClick={(e) => {
e.stopPropagation()
onBooleanToggle()
}}
>
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{isNull ? (
<span className='text-[var(--text-muted)] italic'></span>
) : boolValue ? (
'true'
) : (
'false'
)}
</span>
</button>
)
}
if (isNull) {
return (
<span
className='cursor-text text-[var(--text-muted)] italic'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{isNull ? '' : boolValue ? 'true' : 'false'}
</span>
)
}
if (isNull) return null
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'json')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{jsonStr}
</button>
<span className='block truncate font-mono text-[11px] text-[var(--text-secondary)]'>
{JSON.stringify(value)}
</span>
)
}
if (column.type === 'number') {
return (
<span
className='cursor-text font-mono text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
@@ -543,66 +644,13 @@ function CellContent({
hour: '2-digit',
minute: '2-digit',
})
return (
<span
className='cursor-text text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{formatted}
</span>
)
return <span className='text-[12px] text-[var(--text-secondary)]'>{formatted}</span>
} catch {
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
)
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'text')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{strValue}
</button>
)
}
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{strValue}
</span>
)
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
function InlineEditor({
@@ -631,10 +679,8 @@ function InlineEditor({
const handleSave = () => {
if (doneRef.current) return
doneRef.current = true
try {
const cleaned = cleanCellValue(draft, column)
onSave(cleaned)
onSave(cleanCellValue(draft, column))
} catch {
onCancel()
}
@@ -661,7 +707,14 @@ function InlineEditor({
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset'
className={cn(
'h-full w-full min-w-0 border-none bg-transparent p-0 outline-none',
column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: column.type === 'date'
? 'text-[12px] text-[var(--text-secondary)]'
: 'text-[13px] text-[var(--text-primary)]'
)}
/>
)
}
@@ -708,7 +761,7 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<tr key={rowIndex}>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
<td className={CELL_CHECKBOX}>
<Skeleton className='h-[14px] w-[14px]' />
</td>
{columns.map((col, colIndex) => {
@@ -724,14 +777,10 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
const width = baseWidth + ((rowIndex + colIndex) % 3) * 20
return (
<td
key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
>
<td key={col.name} className={CELL}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</td>
)
@@ -742,64 +791,81 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
)
}
const PLACEHOLDER_ROW_COUNT = 50
function PlaceholderRows({
columns,
editingEmptyCell,
pendingPlaceholders,
selectedCell,
onDoubleClick,
onSave,
onCancel,
onSelectCell,
}: {
columns: ColumnDefinition[]
editingEmptyCell: { rowIndex: number; columnName: string } | null
pendingPlaceholders: Record<number, PendingPlaceholder>
selectedCell: SelectedCell | null
onDoubleClick: (rowIndex: number, columnName: string) => void
onSave: (columnName: string, value: unknown) => void
onSave: (rowIndex: number, columnName: string, value: unknown) => void
onCancel: () => void
onSelectCell: (index: number, columnName: string) => void
}) {
return (
<>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => (
<tr key={`placeholder-${i}`}>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle' />
{columns.map((col) => {
const isEditing =
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => {
const pending = pendingPlaceholders[i]
return (
<tr key={`placeholder-${i}`}>
<td className={CELL_CHECKBOX} />
{columns.map((col) => {
const isEditing =
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
const pendingValue = pending?.data[col.name]
const hasPendingValue = pendingValue !== undefined && pendingValue !== null
const isColumnSelected =
selectedCell?.kind === 'placeholder' &&
selectedCell.index === i &&
selectedCell.columnName === col.name
return (
<td
key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
onDoubleClick={() => onDoubleClick(i, col.name)}
>
{isEditing ? (
<InlineEditor
value={null}
column={col}
onSave={(value) => onSave(col.name, value)}
onCancel={onCancel}
/>
) : (
<div className='min-h-[20px]' />
)}
</td>
)
})}
</tr>
))}
return (
<td
key={col.name}
className={cn(CELL, isColumnSelected && 'relative z-[11]')}
onClick={() => onSelectCell(i, col.name)}
onDoubleClick={() => onDoubleClick(i, col.name)}
>
{isColumnSelected && <SelectionOverlay />}
{isEditing ? (
<InlineEditor
value={hasPendingValue ? pendingValue : null}
column={col}
onSave={(value) => onSave(i, col.name, value)}
onCancel={onCancel}
/>
) : hasPendingValue ? (
<div className={CELL_CONTENT}>
<CellContent
value={pendingValue}
column={col}
isEditing={false}
onSave={() => {}}
onCancel={() => {}}
/>
</div>
) : (
<div className='min-h-[20px]' />
)}
</td>
)
})}
</tr>
)
})}
</>
)
}
const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
string: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
date: CalendarIcon,
json: TypeJson,
}
function ColumnTypeIcon({ type }: { type: string }) {
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
return <Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
return <Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
}

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
import { Table } from './components'
export const metadata: Metadata = {
title: 'Table',
}
export default function TablePage() {
return <Table />
}

View File

@@ -1,330 +0,0 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Trash2 } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
import { useCreateTable } from '@/hooks/queries/tables'
const logger = createLogger('CreateModal')
interface CreateModalProps {
isOpen: boolean
onClose: () => void
}
const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'date', label: 'Date' },
{ value: 'json', label: 'JSON' },
]
interface ColumnWithId extends ColumnDefinition {
id: string
}
function createEmptyColumn(): ColumnWithId {
return { id: nanoid(), name: '', type: 'string', required: true, unique: false }
}
export function CreateModal({ isOpen, onClose }: CreateModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [tableName, setTableName] = useState('')
const [description, setDescription] = useState('')
const [columns, setColumns] = useState<ColumnWithId[]>([createEmptyColumn()])
const [error, setError] = useState<string | null>(null)
const createTable = useCreateTable(workspaceId)
const handleAddColumn = () => {
setColumns([...columns, createEmptyColumn()])
}
const handleRemoveColumn = (columnId: string) => {
if (columns.length > 1) {
setColumns(columns.filter((col) => col.id !== columnId))
}
}
const handleColumnChange = (
columnId: string,
field: keyof ColumnDefinition,
value: string | boolean
) => {
setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col)))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!tableName.trim()) {
setError('Table name is required')
return
}
// Validate column names
const validColumns = columns.filter((col) => col.name.trim())
if (validColumns.length === 0) {
setError('At least one column is required')
return
}
// Check for duplicate column names
const columnNames = validColumns.map((col) => col.name.toLowerCase())
const uniqueNames = new Set(columnNames)
if (uniqueNames.size !== columnNames.length) {
setError('Duplicate column names found')
return
}
// Strip internal IDs before sending to API
const columnsForApi = validColumns.map(({ id: _id, ...col }) => col)
try {
await createTable.mutateAsync({
name: tableName,
description: description || undefined,
schema: {
columns: columnsForApi,
},
})
// Reset form
resetForm()
onClose()
} catch (err) {
logger.error('Failed to create table:', err)
setError(err instanceof Error ? err.message : 'Failed to create table')
}
}
const resetForm = () => {
setTableName('')
setDescription('')
setColumns([createEmptyColumn()])
setError(null)
}
const handleClose = () => {
resetForm()
onClose()
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent size='lg'>
<ModalHeader>Create Table</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Define your table schema with columns and constraints.
</p>
{error && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tableName'>Name</Label>
<Input
id='tableName'
value={tableName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setTableName(e.target.value)
}
placeholder='customers, orders, products'
className={cn(
error === 'Table name is required' && 'border-[var(--text-error)]'
)}
required
/>
<p className='text-[11px] text-[var(--text-muted)]'>
Use lowercase with underscores (e.g., customer_orders)
</p>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setDescription(e.target.value)
}
placeholder='Optional description for this table'
rows={3}
className='resize-none'
/>
</div>
<div className='space-y-[8px]'>
<div className='flex items-center justify-between'>
<Label>Columns*</Label>
<Button
type='button'
size='sm'
variant='default'
onClick={handleAddColumn}
className='h-[30px] rounded-[6px] px-[12px] text-[12px]'
>
<Plus className='mr-[4px] h-[14px] w-[14px]' />
Add Column
</Button>
</div>
<div className='space-y-[8px]'>
{columns.map((column, index) => (
<ColumnRow
key={column.id}
index={index}
column={column}
isRemovable={columns.length > 1}
onChange={handleColumnChange}
onRemove={handleRemoveColumn}
/>
))}
</div>
<p className='text-[11px] text-[var(--text-muted)]'>
Mark columns as <span className='font-medium'>unique</span> to prevent duplicate
values (e.g., id, email)
</p>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className='flex w-full items-center justify-end gap-[8px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
disabled={createTable.isPending}
>
Cancel
</Button>
<Button
type='submit'
variant='tertiary'
disabled={createTable.isPending}
className='min-w-[120px]'
>
{createTable.isPending ? 'Creating...' : 'Create Table'}
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}
interface ColumnRowProps {
index: number
column: ColumnWithId
isRemovable: boolean
onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
onRemove: (columnId: string) => void
}
function ColumnRow({ index, column, isRemovable, onChange, onRemove }: ColumnRowProps) {
return (
<div className='rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)] p-[10px]'>
<div className='mb-[8px] flex items-center justify-between'>
<span className='font-medium text-[11px] text-[var(--text-tertiary)]'>
Column {index + 1}
</span>
<Button
type='button'
size='sm'
variant='ghost'
onClick={() => onRemove(column.id)}
disabled={!isRemovable}
className='h-[28px] w-[28px] p-0 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[15px] w-[15px]' />
</Button>
</div>
<div className='grid grid-cols-[minmax(0,1fr)_120px_76px_76px] items-end gap-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label
htmlFor={`column-name-${column.id}`}
className='text-[11px] text-[var(--text-muted)]'
>
Name
</Label>
<Input
id={`column-name-${column.id}`}
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(column.id, 'name', e.target.value)
}
placeholder='column_name'
className='h-[36px]'
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label
htmlFor={`column-type-${column.id}`}
className='text-[11px] text-[var(--text-muted)]'
>
Type
</Label>
<Combobox
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-[36px]'
/>
</div>
<div className='flex flex-col items-center gap-[8px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>Required</span>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
/>
</div>
<div className='flex flex-col items-center gap-[8px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>Unique</span>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
/>
</div>
</div>
</div>
)
}

View File

@@ -1 +0,0 @@
export { CreateModal } from './create-modal'

View File

@@ -1,4 +1,3 @@
export * from './create-modal'
export * from './empty-state'
export * from './error-state'
export * from './loading-state'

View File

@@ -1,9 +1,14 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Tables } from './tables'
export const metadata: Metadata = {
title: 'Tables',
}
interface TablesPageProps {
params: Promise<{
workspaceId: string

View File

@@ -6,14 +6,15 @@ import { useParams, useRouter } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
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 { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { CreateModal, TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteTable, useTablesList } from '@/hooks/queries/tables'
import { useCreateTable, useDeleteTable, useTablesList } from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
const logger = createLogger('Tables')
@@ -40,8 +41,8 @@ export function Tables() {
logger.error('Failed to load tables:', error)
}
const deleteTable = useDeleteTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
@@ -100,10 +101,6 @@ export function Tables() {
[filteredTables, members]
)
const handleSort = useCallback(() => {}, [])
const handleFilter = useCallback(() => {}, [])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -147,6 +144,23 @@ export function Tables() {
}
}
const handleCreateTable = useCallback(async () => {
const existingNames = tables.map((t) => t.name)
const name = generateUniqueTableName(existingNames)
try {
const result = await createTable.mutateAsync({
name,
schema: { columns: [{ name: 'name', type: 'string' }] },
})
const tableId = result?.data?.table?.id
if (tableId) {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
} catch (err) {
logger.error('Failed to create table:', err)
}
}, [tables, createTable, router, workspaceId])
return (
<>
<Resource
@@ -154,8 +168,8 @@ export function Tables() {
title='Tables'
create={{
label: 'New table',
onClick: () => setIsCreateModalOpen(true),
disabled: userPermissions.canEdit !== true,
onClick: handleCreateTable,
disabled: userPermissions.canEdit !== true || createTable.isPending,
}}
search={{
value: searchTerm,
@@ -163,8 +177,8 @@ export function Tables() {
placeholder: 'Search tables...',
}}
defaultSort='created'
onSort={handleSort}
onFilter={handleFilter}
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
@@ -178,8 +192,8 @@ export function Tables() {
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
onCreateTable={() => setIsCreateModalOpen(true)}
disableCreate={userPermissions.canEdit !== true}
onCreateTable={handleCreateTable}
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
/>
<TableContextMenu
@@ -235,8 +249,6 @@ export function Tables() {
tableName={activeTable.name}
/>
)}
<CreateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
</>
)
}

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
export const metadata: Metadata = {
title: 'Task',
}
interface TaskPageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -8,6 +9,10 @@ import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Templates',
}
interface TemplatesPageProps {
params: Promise<{
workspaceId: string

View File

@@ -1,3 +1,8 @@
import type { Metadata } from 'next'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
export const metadata: Metadata = {
title: 'Workflow',
}
export default Workflow

View File

@@ -0,0 +1,218 @@
/**
* Dropdown menu component built on Radix UI primitives with EMCN styling.
* Provides accessible, animated dropdown menus with consistent design tokens.
*
* @example
* ```tsx
* <DropdownMenu>
* <DropdownMenuTrigger asChild>
* <Button>Open</Button>
* </DropdownMenuTrigger>
* <DropdownMenuContent>
* <DropdownMenuLabel>Actions</DropdownMenuLabel>
* <DropdownMenuSeparator />
* <DropdownMenuItem>Edit</DropdownMenuItem>
* <DropdownMenuItem>Delete</DropdownMenuItem>
* </DropdownMenuContent>
* </DropdownMenu>
* ```
*/
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-[8px] rounded-[6px] px-[6px] py-[4px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] data-[state=open]:bg-[var(--border-1)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
inset && 'pl-[28px]',
className
)}
{...props}
>
{children}
<ChevronRight className='ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
ANIMATION_CLASSES,
'z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-[6px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-primary)] shadow-lg dark:bg-[var(--bg)]',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
ANIMATION_CLASSES,
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[6px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-primary)] shadow-md dark:bg-[var(--bg)]',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-[8px] rounded-[6px] px-[6px] py-[4px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
inset && 'pl-[28px]',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-[6px] py-[4px] pr-[6px] pl-[28px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className='absolute left-[6px] flex h-[14px] w-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-[14px] w-[14px]' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-[6px] py-[4px] pr-[6px] pl-[28px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className='absolute left-[6px] flex h-[14px] w-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-[6px] w-[6px] fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-[6px] py-[4px] font-medium text-[11px] text-[var(--text-tertiary)]',
inset && 'pl-[28px]',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-[6px] my-[6px] h-px bg-[var(--border-1)]', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-[11px] text-[var(--text-muted)] tracking-widest', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -37,6 +37,23 @@ export {
type ComboboxOptionGroup,
} from './combobox/combobox'
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './dropdown-menu/dropdown-menu'
export { Input, type InputProps, inputVariants } from './input/input'
export { Label } from './label/label'
export {

View File

@@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000200] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
@@ -56,15 +56,13 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, avoidCollisions = false, sticky = 'always', ...props }, ref) => (
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
sticky={sticky as any}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000200] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
@@ -82,7 +80,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
@@ -98,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
@@ -121,7 +119,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}

View File

@@ -86,6 +86,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
manifest: '/manifest.webmanifest',
icons: {
icon: [
...(brand.faviconUrl ? [] : [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }]),
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{
@@ -98,10 +99,10 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
sizes: '512x512',
type: 'image/png',
},
{ url: brand.faviconUrl || '/sim.png', sizes: 'any', type: 'image/png' },
...(brand.faviconUrl ? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }] : []),
],
apple: '/favicon/apple-touch-icon.png',
shortcut: brand.faviconUrl || '/favicon/favicon.ico',
shortcut: brand.faviconUrl || '/icon.svg',
},
appleWebApp: {
capable: true,

View File

@@ -229,6 +229,39 @@ export function useCreateTable(workspaceId: string) {
})
}
/**
* Add a column to an existing table.
*/
export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (column: {
name: string
type: string
required?: boolean
unique?: boolean
}) => {
const res = await fetch(`/api/table/${tableId}/columns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, column }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to add column')
}
return res.json()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
},
})
}
/**
* Delete a table from a workspace.
*/

View File

@@ -6,7 +6,7 @@ import type { BrandConfig } from './types'
export const defaultBrandConfig: BrandConfig = {
name: 'Sim',
logoUrl: undefined,
faviconUrl: '/favicon/favicon.ico',
faviconUrl: undefined,
customCssUrl: undefined,
supportEmail: 'help@sim.ai',
documentationUrl: undefined,

View File

@@ -57,3 +57,221 @@ export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as c
export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i
export const USER_TABLE_ROWS_SQL_NAME = 'user_table_rows'
const TABLE_NAME_ADJECTIVES = [
'Radiant',
'Luminous',
'Blazing',
'Glowing',
'Bright',
'Gleaming',
'Shining',
'Lustrous',
'Vivid',
'Dazzling',
'Stellar',
'Cosmic',
'Astral',
'Galactic',
'Nebular',
'Orbital',
'Lunar',
'Solar',
'Starlit',
'Celestial',
'Infinite',
'Vast',
'Boundless',
'Immense',
'Colossal',
'Titanic',
'Grand',
'Supreme',
'Eternal',
'Ancient',
'Timeless',
'Primal',
'Nascent',
'Elder',
'Swift',
'Drifting',
'Surging',
'Pulsing',
'Soaring',
'Rising',
'Spiraling',
'Crimson',
'Azure',
'Violet',
'Indigo',
'Amber',
'Sapphire',
'Obsidian',
'Silver',
'Golden',
'Scarlet',
'Cobalt',
'Emerald',
'Magnetic',
'Quantum',
'Photonic',
'Spectral',
'Charged',
'Atomic',
'Electric',
'Kinetic',
'Ethereal',
'Mystic',
'Phantom',
'Silent',
'Distant',
'Hidden',
'Arcane',
'Frozen',
'Burning',
'Molten',
'Volatile',
'Fiery',
'Searing',
'Frigid',
'Mighty',
'Fierce',
'Serene',
'Tranquil',
'Harmonic',
'Resonant',
'Bold',
'Noble',
'Pure',
'Rare',
'Pristine',
'Exotic',
'Divine',
] as const
const TABLE_NAME_NOUNS = [
'Star',
'Pulsar',
'Quasar',
'Magnetar',
'Nova',
'Supernova',
'Neutron',
'Protostar',
'Blazar',
'Cepheid',
'Galaxy',
'Nebula',
'Cluster',
'Void',
'Filament',
'Halo',
'Spiral',
'Remnant',
'Cloud',
'Planet',
'Moon',
'World',
'Exoplanet',
'Titan',
'Europa',
'Triton',
'Enceladus',
'Comet',
'Meteor',
'Asteroid',
'Fireball',
'Shard',
'Fragment',
'Orion',
'Andromeda',
'Perseus',
'Pegasus',
'Phoenix',
'Draco',
'Cygnus',
'Aquila',
'Lyra',
'Vega',
'Hydra',
'Sirius',
'Polaris',
'Altair',
'Eclipse',
'Aurora',
'Corona',
'Flare',
'Vortex',
'Pulse',
'Wave',
'Ripple',
'Shimmer',
'Spark',
'Horizon',
'Zenith',
'Apex',
'Meridian',
'Equinox',
'Solstice',
'Transit',
'Orbit',
'Cosmos',
'Dimension',
'Realm',
'Expanse',
'Infinity',
'Continuum',
'Abyss',
'Ether',
'Photon',
'Neutrino',
'Tachyon',
'Graviton',
'Sector',
'Quadrant',
'Belt',
'Ring',
'Field',
'Stream',
'Frontier',
'Beacon',
'Signal',
'Probe',
'Voyager',
'Pioneer',
'Sentinel',
'Gateway',
'Portal',
'Nexus',
'Conduit',
'Rift',
'Core',
'Matrix',
'Lattice',
'Array',
'Reactor',
'Engine',
'Forge',
'Crucible',
] as const
/**
* Generates a unique space-themed table name that doesn't collide with existing names.
* Uses lowercase with underscores to satisfy NAME_PATTERN validation.
*/
export function generateUniqueTableName(existingNames: string[]): string {
const taken = new Set(existingNames.map((n) => n.toLowerCase()))
const maxAttempts = 50
for (let i = 0; i < maxAttempts; i++) {
const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
const name = `${adj.toLowerCase()}_${noun.toLowerCase()}`
if (!taken.has(name)) return name
}
const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
const suffix = Math.floor(Math.random() * 900) + 100
return `${adj.toLowerCase()}_${noun.toLowerCase()}_${suffix}`
}

View File

@@ -213,6 +213,63 @@ export async function createTable(
}
}
/**
* Adds a column to an existing table's schema.
*
* @param tableId - Table ID to update
* @param column - Column definition to add
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if table not found or column name already exists
*/
export async function addTableColumn(
tableId: string,
column: { name: string; type: string; required?: boolean; unique?: boolean },
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(tableId)
if (!table) {
throw new Error('Table not found')
}
const schema = table.schema
if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) {
throw new Error(`Column "${column.name}" already exists`)
}
if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
throw new Error(
`Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})`
)
}
const newColumn = {
name: column.name,
type: column.type as TableSchema['columns'][number]['type'],
required: column.required ?? false,
unique: column.unique ?? false,
}
const updatedSchema: TableSchema = {
columns: [...schema.columns, newColumn],
}
const now = new Date()
await db
.update(userTableDefinitions)
.set({ schema: updatedSchema, updatedAt: now })
.where(eq(userTableDefinitions.id, tableId))
logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`)
return {
...table,
schema: updatedSchema,
updatedAt: now,
}
}
/**
* Deletes a table (hard delete).
*

View File

@@ -369,6 +369,10 @@ const nextConfig: NextConfig = {
},
async rewrites() {
return [
{
source: '/favicon.ico',
destination: '/icon.svg',
},
{
source: '/r/:shortCode',
destination: 'https://go.trybeluga.ai/:shortCode',

View File

@@ -61,7 +61,7 @@
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2",

11
apps/sim/public/icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="800" height="386" viewBox="0 0 800 386" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.wm { fill: #1C1C1C; }
@media (prefers-color-scheme: dark) {
.wm { fill: #FFFFFF; }
}
</style>
<path class="wm" d="M0 293.75H53.4128C53.4128 308.498 58.7541 320.256 69.4367 329.025C80.1193 337.395 94.5605 341.58 112.76 341.58C132.543 341.58 147.776 337.794 158.458 330.22C169.141 322.249 174.482 311.686 174.482 298.533C174.482 288.967 171.515 280.995 165.58 274.618C160.041 268.24 149.754 263.059 134.719 259.073L83.6801 247.115C57.9628 240.738 38.7738 230.973 26.1129 217.819C13.8478 204.666 7.71519 187.328 7.71519 165.804C7.71519 147.868 12.2652 132.323 21.3651 119.169C30.8608 106.016 43.7194 95.8521 59.9411 88.6776C76.5584 81.5031 95.5497 77.9157 116.915 77.9157C138.28 77.9157 156.678 81.7023 172.108 89.2755C187.934 96.8486 200.199 107.411 208.904 120.963C218.004 134.515 222.751 150.658 223.147 169.391H169.734C169.339 154.245 164.393 142.487 154.897 134.116C145.402 125.746 132.147 121.561 115.134 121.561C97.7257 121.561 84.2736 125.347 74.778 132.921C65.2824 140.494 60.5346 150.857 60.5346 164.01C60.5346 183.541 74.778 196.894 103.265 204.068L154.304 216.624C178.834 222.204 197.232 231.371 209.497 244.126C221.762 256.482 227.895 273.422 227.895 294.946C227.895 313.281 222.949 329.423 213.058 343.374C203.167 356.926 189.517 367.488 172.108 375.061C155.095 382.236 134.917 385.823 111.574 385.823C77.5475 385.823 50.4455 377.453 30.2673 360.712C10.0891 343.972 0 321.651 0 293.75Z"/>
<path class="wm" d="M267.175 385.826V93.4629C289.419 101.596 299.228 101.596 322.962 93.4629V385.826H267.175ZM294.475 74.1369C284.584 74.1369 275.879 70.5497 268.362 63.3751C261.24 55.802 257.679 47.0331 257.679 37.0684C257.679 26.7052 261.24 17.9364 268.362 10.7618C275.879 3.58727 284.584 0 294.475 0C304.762 0 313.466 3.58727 320.588 10.7618C327.71 17.9364 331.27 26.7052 331.27 37.0684C331.27 47.0331 327.71 55.802 320.588 63.3751C313.466 70.5497 304.762 74.1369 294.475 74.1369Z"/>
<path class="wm" d="M421.362 385.823H365.576V93.4606H415.428V142.79C421.362 126.448 432.836 112.593 448.662 101.831C464.884 90.6705 484.469 85.0903 507.416 85.0903C533.134 85.0903 554.499 92.0655 571.512 106.016C588.525 119.967 599.603 138.501 604.746 161.619H594.657C598.614 138.501 609.494 119.967 627.299 106.016C645.103 92.0655 667.061 85.0903 693.174 85.0903C726.409 85.0903 752.522 94.8556 771.513 114.386C790.504 133.917 800 160.622 800 194.502V385.823H745.4V208.253C745.4 185.135 739.466 167.398 727.596 155.042C716.122 142.287 700.494 135.91 680.711 135.91C666.864 135.91 654.598 139.099 643.916 145.476C633.629 151.455 625.518 160.224 619.583 171.783C613.649 183.342 610.681 196.894 610.681 212.438V385.823H555.488V207.655C555.488 184.537 549.751 167 538.277 155.042C526.803 142.686 511.175 136.508 491.392 136.508C477.545 136.508 465.28 139.697 454.597 146.074C444.31 152.053 436.199 160.822 430.264 172.381C424.33 183.541 421.362 196.894 421.362 212.438V385.823Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB