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' export const metadata: Metadata = {
import AuthBackground from '@/app/(auth)/components/auth-background' robots: { index: false, follow: false },
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 default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
useEffect(() => { return <AuthLayoutClient>{children}</AuthLayoutClient>
// 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>
)
} }

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form' import LoginForm from '@/app/(auth)/login/login-form'
export const metadata: Metadata = {
title: 'Log In',
}
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function LoginPage() { 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' export const metadata: Metadata = {
import { createLogger } from '@sim/logger' title: 'Reset Password',
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() { export default ResetPasswordPage
return (
<Suspense
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}

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 { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form' import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
title: 'Sign Up',
}
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function SignupPage() { export default async function SignupPage() {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form' import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
title: 'Single Sign-On',
}
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function SSOPage() { 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 { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer' import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content' import { VerifyContent } from '@/app/(auth)/verify/verify-content'
export const metadata: Metadata = {
title: 'Verify Email',
}
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default function VerifyPage() { export default function VerifyPage() {

View File

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

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry' import { getAllPostMeta } from '@/lib/blog/registry'
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600 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 }> }) { export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) 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 Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry' import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid' 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 const revalidate = 3600
export default async function StudioIndex({ export default async function StudioIndex({

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { getAllTags } from '@/lib/blog/registry' import { getAllTags } from '@/lib/blog/registry'
export const metadata: Metadata = {
title: 'Tags',
}
export default async function TagsIndex() { export default async function TagsIndex() {
const tags = await getAllTags() const tags = await getAllTags()
return ( 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' import ChatClient from '@/app/chat/[identifier]/chat'
export const metadata: Metadata = {
title: 'Chat',
}
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) { export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params const { identifier } = await params
return <ChatClient identifier={identifier} /> 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' import Form from '@/app/form/[identifier]/form'
export const metadata: Metadata = {
title: 'Form',
}
export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) { export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params const { identifier } = await params
return <Form identifier={identifier} /> return <Form identifier={identifier} />

View File

@@ -1,3 +1,9 @@
import type { Metadata } from 'next'
import Invite from '@/app/invite/[id]/invite' import Invite from '@/app/invite/[id]/invite'
export const metadata: Metadata = {
title: 'Invite',
robots: { index: false },
}
export default Invite 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 { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client' 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 runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'

View File

@@ -1,11 +1,18 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema' import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm' import { and, desc, eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import type { Template } from '@/app/templates/templates' import type { Template } from '@/app/templates/templates'
import Templates 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. * Public templates list page.
* Redirects authenticated users to their workspace-scoped templates 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' import Unsubscribe from '@/app/unsubscribe/unsubscribe'
export const metadata: Metadata = {
title: 'Unsubscribe',
robots: { index: false },
}
export default Unsubscribe export default Unsubscribe

View File

@@ -32,7 +32,7 @@ export function ResourceOptionsBar({
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
{search && ( {search && (
<div className='flex flex-1 items-center'> <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 <input
type='text' type='text'
value={search.value} value={search.value}
@@ -45,13 +45,13 @@ export function ResourceOptionsBar({
<div className='flex items-center gap-[6px]'> <div className='flex items-center gap-[6px]'>
{onFilter && ( {onFilter && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={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 Filter
</Button> </Button>
)} )}
{onSort && ( {onSort && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={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 Sort
</Button> </Button>
)} )}

View File

@@ -137,7 +137,7 @@ export function Resource({
> >
{col.header} {col.header}
{sort.column === col.id && ( {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> </Button>
</th> </th>
@@ -210,7 +210,7 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]' 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 className='truncate'>{cell.label}</span>
</span> </span>
) )

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document' import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
interface DocumentPageProps { 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) { export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) {
const { id, documentId } = await params const { id, documentId } = await params
const { kbName, docName } = await searchParams 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' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
interface PageProps { 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) { export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
const { id } = await params const { id } = await params
const { kbName } = await searchParams const { kbName } = await searchParams

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader /> <SettingsLoader />
<ProviderModelsLoader /> <ProviderModelsLoader />
<GlobalCommandsProvider> <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> <WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning> <div className='shrink-0' suppressHydrationWarning>
<Sidebar /> <Sidebar />

View File

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

View File

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

View File

@@ -1,12 +1,40 @@
'use client' import type { Metadata } from 'next'
import { useParams } from 'next/navigation'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { SettingsPage } from './settings' import { SettingsPage } from './settings'
export default function SettingsSectionPage() { const SECTION_TITLES: Record<string, string> = {
const params = useParams() general: 'General',
const section = params.section as SettingsSection 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 { Button, Checkbox, Skeleton } from '@/components/emcn'
import { import {
Calendar as CalendarIcon, Calendar as CalendarIcon,
Plus,
Table as TableIcon, Table as TableIcon,
TypeBoolean, TypeBoolean,
TypeJson, TypeJson,
@@ -14,17 +15,46 @@ import {
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table' import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components' import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables' import { useAddTableColumn, useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
import { STRING_TRUNCATE_LENGTH } from '../../constants'
import { useContextMenu, useRowSelection, useTableData } from '../../hooks' 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 { cleanCellValue, formatValueForInput } from '../../utils'
import { CellViewerModal } from '../cell-viewer-modal'
import { ContextMenu } from '../context-menu' import { ContextMenu } from '../context-menu'
import { RowModal } from '../row-modal' import { RowModal } from '../row-modal'
import { SchemaModal } from '../schema-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 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() { export function Table() {
const params = useParams() const params = useParams()
@@ -44,15 +74,16 @@ export function Table() {
const [deletingRows, setDeletingRows] = useState<string[]>([]) const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false) const [showSchemaModal, setShowSchemaModal] = useState(false)
const [editingCell, setEditingCell] = useState<EditingCell | null>(null) 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<{ const [editingEmptyCell, setEditingEmptyCell] = useState<{
rowIndex: number rowIndex: number
columnName: string columnName: string
} | null>(null) } | null>(null)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({ const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
workspaceId, workspaceId,
tableId, tableId,
@@ -70,17 +101,34 @@ export function Table() {
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId }) const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
const columns = useMemo( const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS, () => tableData?.schema?.columns || EMPTY_COLUMNS,
[tableData?.schema?.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 selectedCount = selectedRows.size
const hasSelection = selectedCount > 0 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 columnsRef = useRef(columns)
const rowsRef = useRef(rows) const rowsRef = useRef(rows)
const pendingPlaceholdersRef = useRef(pendingPlaceholders)
useEffect(() => { useEffect(() => {
columnsRef.current = columns columnsRef.current = columns
@@ -88,6 +136,9 @@ export function Table() {
useEffect(() => { useEffect(() => {
rowsRef.current = rows rowsRef.current = rows
}, [rows]) }, [rows])
useEffect(() => {
pendingPlaceholdersRef.current = pendingPlaceholders
}, [pendingPlaceholders])
const handleNavigateBack = useCallback(() => { const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`) router.push(`/workspace/${workspaceId}/tables`)
@@ -97,10 +148,6 @@ export function Table() {
setShowAddModal(true) setShowAddModal(true)
}, []) }, [])
const handleSort = useCallback(() => {}, [])
const handleFilter = useCallback(() => {}, [])
const handleDeleteSelected = useCallback(() => { const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows)) setDeletingRows(Array.from(selectedRows))
}, [selectedRows]) }, [selectedRows])
@@ -127,30 +174,17 @@ export function Table() {
[baseHandleRowContextMenu] [baseHandleRowContextMenu]
) )
const handleCopyCellValue = useCallback(async () => { const handleSelectCell = useCallback((rowId: string, columnName: string) => {
if (cellViewer) { setSelectedCell({ kind: 'data', rowId, columnName })
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 handleCellClick = useCallback( const handleSelectPlaceholderCell = useCallback((index: number, columnName: string) => {
(columnName: string, value: unknown, type: CellViewerData['type']) => { setSelectedCell({ kind: 'placeholder', index, columnName })
setCellViewer({ columnName, value, type }) }, [])
},
[]
)
const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => { const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => {
setSelectedCell({ kind: 'data', rowId, columnName })
const column = columnsRef.current.find((c) => c.name === columnName) const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return if (!column) return
@@ -160,7 +194,13 @@ export function Table() {
return 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 }) setEditingCell({ rowId, columnName })
}, []) }, [])
@@ -170,6 +210,60 @@ export function Table() {
mutateRef.current = updateRowMutation.mutate mutateRef.current = updateRowMutation.mutate
}, [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) => { const handleInlineSave = useCallback((rowId: string, columnName: string, value: unknown) => {
setEditingCell(null) setEditingCell(null)
@@ -187,13 +281,6 @@ export function Table() {
setEditingCell(null) 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 handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName) const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'json' || column.type === 'boolean') return if (!column || column.type === 'json' || column.type === 'boolean') return
@@ -205,16 +292,65 @@ export function Table() {
createRef.current = createRowMutation.mutate createRef.current = createRowMutation.mutate
}, [createRowMutation.mutate]) }, [createRowMutation.mutate])
const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => { const handleEmptyRowSave = useCallback((rowIndex: number, columnName: string, value: unknown) => {
setEditingEmptyCell(null) setEditingEmptyCell(null)
if (value === null || value === undefined || value === '') return 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(() => { const handleEmptyRowCancel = useCallback(() => {
setEditingEmptyCell(null) 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) { if (isLoadingTable) {
return ( return (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
@@ -238,34 +374,42 @@ export function Table() {
breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]} 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'> <div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='border-collapse text-[13px]'> <table
<colgroup> className='table-fixed border-separate border-spacing-0 text-[13px]'
<col className='w-[40px]' /> style={{ width: `${tableWidth}px` }}
{columns.map((col) => ( >
<col key={col.name} className='w-[160px]' /> <TableColGroup columns={columns} />
))} <thead className='sticky top-0 z-10'>
</colgroup>
<thead className='sticky top-0 z-10 bg-white shadow-[inset_0_-1px_0_var(--border)] dark:bg-[var(--bg)]'>
<tr> <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} /> <Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</th> </th>
{columns.map((column) => ( {columns.map((column) => (
<th <th key={column.name} className={CELL_HEADER}>
key={column.name} <div className='flex min-w-0 items-center gap-[8px]'>
className='border-[var(--border)] border-r px-[24px] py-[10px] text-left align-middle'
>
<div className='flex items-center gap-[8px]'>
<ColumnTypeIcon type={column.type} /> <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} {column.name}
</span> </span>
</div> </div>
</th> </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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -273,7 +417,7 @@ export function Table() {
<TableSkeleton columns={columns} /> <TableSkeleton columns={columns} />
) : ( ) : (
<> <>
{rows.map((row) => ( {visibleRows.map((row) => (
<DataRow <DataRow
key={row.id} key={row.id}
row={row} row={row}
@@ -282,21 +426,28 @@ export function Table() {
editingColumnName={ editingColumnName={
editingCell?.rowId === row.id ? editingCell.columnName : null editingCell?.rowId === row.id ? editingCell.columnName : null
} }
onCellClick={handleCellClick} selectedColumnName={
selectedCell?.kind === 'data' && selectedCell.rowId === row.id
? selectedCell.columnName
: null
}
onDoubleClick={handleCellDoubleClick} onDoubleClick={handleCellDoubleClick}
onSave={handleInlineSave} onSave={handleInlineSave}
onCancel={handleInlineCancel} onCancel={handleInlineCancel}
onBooleanToggle={handleBooleanToggle}
onContextMenu={handleRowContextMenu} onContextMenu={handleRowContextMenu}
onSelectRow={handleSelectRow} onSelectRow={handleSelectRow}
onSelectCell={handleSelectCell}
/> />
))} ))}
<PlaceholderRows <PlaceholderRows
columns={columns} columns={columns}
editingEmptyCell={editingEmptyCell} editingEmptyCell={editingEmptyCell}
pendingPlaceholders={pendingPlaceholders}
selectedCell={selectedCell}
onDoubleClick={handleEmptyRowDoubleClick} onDoubleClick={handleEmptyRowDoubleClick}
onSave={handleEmptyRowSave} onSave={handleEmptyRowSave}
onCancel={handleEmptyRowCancel} onCancel={handleEmptyRowCancel}
onSelectCell={handleSelectPlaceholderCell}
/> />
</> </>
)} )}
@@ -318,9 +469,7 @@ export function Table() {
isOpen={true} isOpen={true}
onClose={() => setShowAddModal(false)} onClose={() => setShowAddModal(false)}
table={tableData} table={tableData}
onSuccess={() => { onSuccess={() => setShowAddModal(false)}
setShowAddModal(false)
}}
/> />
)} )}
@@ -331,9 +480,7 @@ export function Table() {
onClose={() => setEditingRow(null)} onClose={() => setEditingRow(null)}
table={tableData} table={tableData}
row={editingRow} row={editingRow}
onSuccess={() => { onSuccess={() => setEditingRow(null)}
setEditingRow(null)
}}
/> />
)} )}
@@ -358,13 +505,6 @@ export function Table() {
tableName={tableData.name} tableName={tableData.name}
/> />
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
<ContextMenu <ContextMenu
contextMenu={contextMenu} contextMenu={contextMenu}
onClose={closeContextMenu} 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({ const DataRow = React.memo(function DataRow({
row, row,
columns, columns,
isSelected, isSelected,
editingColumnName, editingColumnName,
onCellClick, selectedColumnName,
onDoubleClick, onDoubleClick,
onSave, onSave,
onCancel, onCancel,
onBooleanToggle,
onContextMenu, onContextMenu,
onSelectRow, onSelectRow,
onSelectCell,
}: { }: {
row: TableRowType row: TableRowType
columns: ColumnDefinition[] columns: ColumnDefinition[]
isSelected: boolean isSelected: boolean
editingColumnName: string | null editingColumnName: string | null
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void selectedColumnName: string | null
onDoubleClick: (rowId: string, columnName: string) => void onDoubleClick: (rowId: string, columnName: string) => void
onSave: (rowId: string, columnName: string, value: unknown) => void onSave: (rowId: string, columnName: string, value: unknown) => void
onCancel: () => void onCancel: () => void
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onSelectRow: (rowId: string) => void onSelectRow: (rowId: string) => void
onSelectCell: (rowId: string, columnName: string) => void
}) { }) {
return ( return (
<tr <tr
className={cn('group', isSelected && 'bg-[var(--surface-5)]')} className={cn('group', isSelected && 'bg-[var(--surface-5)]')}
onContextMenu={(e) => onContextMenu(e, row)} 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)} /> <Checkbox size='sm' checked={isSelected} onCheckedChange={() => onSelectRow(row.id)} />
</td> </td>
{columns.map((column) => ( {columns.map((column) => {
<td const isColumnSelected = selectedColumnName === column.name
key={column.name} return (
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle' <td
> key={column.name}
<div className='max-w-[300px] truncate text-[13px]'> className={cn(CELL, isColumnSelected && 'relative z-[11]')}
<CellContent onClick={() => onSelectCell(row.id, column.name)}
value={row.data[column.name]} onDoubleClick={() => onDoubleClick(row.id, column.name)}
column={column} >
isEditing={editingColumnName === column.name} {isColumnSelected && <SelectionOverlay />}
onCellClick={onCellClick} <div className={CELL_CONTENT}>
onDoubleClick={() => onDoubleClick(row.id, column.name)} <CellContent
onSave={(value) => onSave(row.id, column.name, value)} value={row.data[column.name]}
onCancel={onCancel} column={column}
onBooleanToggle={() => isEditing={editingColumnName === column.name}
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name])) onSave={(value) => onSave(row.id, column.name, value)}
} onCancel={onCancel}
/> />
</div> </div>
</td> </td>
))} )
})}
</tr> </tr>
) )
}) })
@@ -437,20 +594,14 @@ function CellContent({
value, value,
column, column,
isEditing, isEditing,
onCellClick,
onDoubleClick,
onSave, onSave,
onCancel, onCancel,
onBooleanToggle,
}: { }: {
value: unknown value: unknown
column: ColumnDefinition column: ColumnDefinition
isEditing: boolean isEditing: boolean
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: () => void
onSave: (value: unknown) => void onSave: (value: unknown) => void
onCancel: () => void onCancel: () => void
onBooleanToggle: () => void
}) { }) {
if (isEditing) { if (isEditing) {
return <InlineEditor value={value} column={column} onSave={onSave} onCancel={onCancel} /> return <InlineEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
@@ -461,75 +612,25 @@ function CellContent({
if (column.type === 'boolean') { if (column.type === 'boolean') {
const boolValue = Boolean(value) const boolValue = Boolean(value)
return ( return (
<button <span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
type='button' {isNull ? '' : boolValue ? 'true' : 'false'}
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> </span>
) )
} }
if (isNull) return null
if (column.type === 'json') { if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return ( return (
<button <span className='block truncate font-mono text-[11px] text-[var(--text-secondary)]'>
type='button' {JSON.stringify(value)}
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)]' </span>
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>
) )
} }
if (column.type === 'number') { if (column.type === 'number') {
return ( return (
<span <span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
className='cursor-text font-mono text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
) )
} }
@@ -543,66 +644,13 @@ function CellContent({
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) })
return ( return <span className='text-[12px] text-[var(--text-secondary)]'>{formatted}</span>
<span
className='cursor-text text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{formatted}
</span>
)
} catch { } catch {
return ( return <span className='text-[var(--text-primary)]'>{String(value)}</span>
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
)
} }
} }
const strValue = String(value) return <span className='text-[var(--text-primary)]'>{String(value)}</span>
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>
)
} }
function InlineEditor({ function InlineEditor({
@@ -631,10 +679,8 @@ function InlineEditor({
const handleSave = () => { const handleSave = () => {
if (doneRef.current) return if (doneRef.current) return
doneRef.current = true doneRef.current = true
try { try {
const cleaned = cleanCellValue(draft, column) onSave(cleanCellValue(draft, column))
onSave(cleaned)
} catch { } catch {
onCancel() onCancel()
} }
@@ -661,7 +707,14 @@ function InlineEditor({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleSave} 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) => ( {Array.from({ length: 25 }).map((_, rowIndex) => (
<tr key={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]' /> <Skeleton className='h-[14px] w-[14px]' />
</td> </td>
{columns.map((col, colIndex) => { {columns.map((col, colIndex) => {
@@ -724,14 +777,10 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
: col.type === 'date' : col.type === 'date'
? 100 ? 100
: 120 : 120
const variation = ((rowIndex + colIndex) % 3) * 20 const width = baseWidth + ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return ( return (
<td <td key={col.name} className={CELL}>
key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} /> <Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</td> </td>
) )
@@ -742,64 +791,81 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
) )
} }
const PLACEHOLDER_ROW_COUNT = 50
function PlaceholderRows({ function PlaceholderRows({
columns, columns,
editingEmptyCell, editingEmptyCell,
pendingPlaceholders,
selectedCell,
onDoubleClick, onDoubleClick,
onSave, onSave,
onCancel, onCancel,
onSelectCell,
}: { }: {
columns: ColumnDefinition[] columns: ColumnDefinition[]
editingEmptyCell: { rowIndex: number; columnName: string } | null editingEmptyCell: { rowIndex: number; columnName: string } | null
pendingPlaceholders: Record<number, PendingPlaceholder>
selectedCell: SelectedCell | null
onDoubleClick: (rowIndex: number, columnName: string) => void onDoubleClick: (rowIndex: number, columnName: string) => void
onSave: (columnName: string, value: unknown) => void onSave: (rowIndex: number, columnName: string, value: unknown) => void
onCancel: () => void onCancel: () => void
onSelectCell: (index: number, columnName: string) => void
}) { }) {
return ( return (
<> <>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => ( {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => {
<tr key={`placeholder-${i}`}> const pending = pendingPlaceholders[i]
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle' /> return (
{columns.map((col) => { <tr key={`placeholder-${i}`}>
const isEditing = <td className={CELL_CHECKBOX} />
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name {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 ( return (
<td <td
key={col.name} key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle' className={cn(CELL, isColumnSelected && 'relative z-[11]')}
onDoubleClick={() => onDoubleClick(i, col.name)} onClick={() => onSelectCell(i, col.name)}
> onDoubleClick={() => onDoubleClick(i, col.name)}
{isEditing ? ( >
<InlineEditor {isColumnSelected && <SelectionOverlay />}
value={null} {isEditing ? (
column={col} <InlineEditor
onSave={(value) => onSave(col.name, value)} value={hasPendingValue ? pendingValue : null}
onCancel={onCancel} column={col}
/> onSave={(value) => onSave(i, col.name, value)}
) : ( onCancel={onCancel}
<div className='min-h-[20px]' /> />
)} ) : hasPendingValue ? (
</td> <div className={CELL_CONTENT}>
) <CellContent
})} value={pendingValue}
</tr> 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 }) { function ColumnTypeIcon({ type }: { type: string }) {
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText 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' import { Table } from './components'
export const metadata: Metadata = {
title: 'Table',
}
export default function TablePage() { export default function TablePage() {
return <Table /> 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 './empty-state'
export * from './error-state' export * from './error-state'
export * from './loading-state' export * from './loading-state'

View File

@@ -1,9 +1,14 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Tables } from './tables' import { Tables } from './tables'
export const metadata: Metadata = {
title: 'Tables',
}
interface TablesPageProps { interface TablesPageProps {
params: Promise<{ params: Promise<{
workspaceId: string 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 { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons' import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table' import type { TableDefinition } from '@/lib/table'
import { generateUniqueTableName } from '@/lib/table/constants'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components' import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } 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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' 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 { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' 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' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
const logger = createLogger('Tables') const logger = createLogger('Tables')
@@ -40,8 +41,8 @@ export function Tables() {
logger.error('Failed to load tables:', error) logger.error('Failed to load tables:', error)
} }
const deleteTable = useDeleteTable(workspaceId) const deleteTable = useDeleteTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false) const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null) const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
@@ -100,10 +101,6 @@ export function Tables() {
[filteredTables, members] [filteredTables, members]
) )
const handleSort = useCallback(() => {}, [])
const handleFilter = useCallback(() => {}, [])
const handleContentContextMenu = useCallback( const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const target = e.target as HTMLElement 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 ( return (
<> <>
<Resource <Resource
@@ -154,8 +168,8 @@ export function Tables() {
title='Tables' title='Tables'
create={{ create={{
label: 'New table', label: 'New table',
onClick: () => setIsCreateModalOpen(true), onClick: handleCreateTable,
disabled: userPermissions.canEdit !== true, disabled: userPermissions.canEdit !== true || createTable.isPending,
}} }}
search={{ search={{
value: searchTerm, value: searchTerm,
@@ -163,8 +177,8 @@ export function Tables() {
placeholder: 'Search tables...', placeholder: 'Search tables...',
}} }}
defaultSort='created' defaultSort='created'
onSort={handleSort} onSort={() => {}}
onFilter={handleFilter} onFilter={() => {}}
columns={COLUMNS} columns={COLUMNS}
rows={rows} rows={rows}
onRowClick={handleRowClick} onRowClick={handleRowClick}
@@ -178,8 +192,8 @@ export function Tables() {
position={listContextMenuPosition} position={listContextMenuPosition}
menuRef={listMenuRef} menuRef={listMenuRef}
onClose={closeListContextMenu} onClose={closeListContextMenu}
onCreateTable={() => setIsCreateModalOpen(true)} onCreateTable={handleCreateTable}
disableCreate={userPermissions.canEdit !== true} disableCreate={userPermissions.canEdit !== true || createTable.isPending}
/> />
<TableContextMenu <TableContextMenu
@@ -235,8 +249,6 @@ export function Tables() {
tableName={activeTable.name} 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' import { Home } from '@/app/workspace/[workspaceId]/home/home'
export const metadata: Metadata = {
title: 'Task',
}
interface TaskPageProps { interface TaskPageProps {
params: Promise<{ params: Promise<{
workspaceId: string workspaceId: string

View File

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

View File

@@ -1,3 +1,8 @@
import type { Metadata } from 'next'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow' import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
export const metadata: Metadata = {
title: 'Workflow',
}
export default 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, type ComboboxOptionGroup,
} from './combobox/combobox' } from './combobox/combobox'
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker' 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 { Input, type InputProps, inputVariants } from './input/input'
export { Label } from './label/label' export { Label } from './label/label'
export { export {

View File

@@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -56,15 +56,13 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<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.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
sticky={sticky as any}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -82,7 +80,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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', inset && 'pl-8',
className className
)} )}
@@ -98,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( 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 className
)} )}
checked={checked} checked={checked}
@@ -121,7 +119,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -86,6 +86,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
manifest: '/manifest.webmanifest', manifest: '/manifest.webmanifest',
icons: { icons: {
icon: [ 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-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', 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', sizes: '512x512',
type: 'image/png', 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', apple: '/favicon/apple-touch-icon.png',
shortcut: brand.faviconUrl || '/favicon/favicon.ico', shortcut: brand.faviconUrl || '/icon.svg',
}, },
appleWebApp: { appleWebApp: {
capable: true, 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. * Delete a table from a workspace.
*/ */

View File

@@ -6,7 +6,7 @@ import type { BrandConfig } from './types'
export const defaultBrandConfig: BrandConfig = { export const defaultBrandConfig: BrandConfig = {
name: 'Sim', name: 'Sim',
logoUrl: undefined, logoUrl: undefined,
faviconUrl: '/favicon/favicon.ico', faviconUrl: undefined,
customCssUrl: undefined, customCssUrl: undefined,
supportEmail: 'help@sim.ai', supportEmail: 'help@sim.ai',
documentationUrl: undefined, 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 NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i
export const USER_TABLE_ROWS_SQL_NAME = 'user_table_rows' 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). * Deletes a table (hard delete).
* *

View File

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

View File

@@ -61,7 +61,7 @@
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5", "@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-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5", "@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2", "@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