mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
improvement: tables, favicon
This commit is contained in:
37
apps/sim/app/(auth)/auth-layout-client.tsx
Normal file
37
apps/sim/app/(auth)/auth-layout-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,10 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
// Helper to detect if a color is dark
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
// Check if brand background is dark and add class accordingly
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
return <AuthLayoutClient>{children}</AuthLayoutClient>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Log In',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
|
||||
@@ -1,117 +1,8 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
text: string
|
||||
}>({
|
||||
type: null,
|
||||
text: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: 'Invalid or missing reset token. Please request a new password reset link.',
|
||||
})
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setStatusMessage({ type: null, text: '' })
|
||||
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
newPassword: password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to reset password')
|
||||
}
|
||||
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
logger.error('Error resetting password:', { error })
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Failed to reset password',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reset Password',
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
export default ResetPasswordPage
|
||||
|
||||
117
apps/sim/app/(auth)/reset-password/reset-password-content.tsx
Normal file
117
apps/sim/app/(auth)/reset-password/reset-password-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Single Sign-On',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SSOPage() {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
|
||||
import { hasEmailService } from '@/lib/messaging/email/mailer'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Verify Email',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function VerifyPage() {
|
||||
|
||||
@@ -4,8 +4,8 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://sim.ai'),
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
apple: '/apple-icon.png',
|
||||
icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
},
|
||||
other: {
|
||||
'msapplication-TileColor': '#000000',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
const author = posts[0]?.author
|
||||
return { title: author?.name ?? 'Author' }
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Studio',
|
||||
description: 'Announcements, insights, and guides from the Sim team.',
|
||||
}
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export default async function StudioIndex({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { getAllTags } from '@/lib/blog/registry'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tags',
|
||||
}
|
||||
|
||||
export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
|
||||
77
apps/sim/app/api/table/[tableId]/columns/route.ts
Normal file
77
apps/sim/app/api/table/[tableId]/columns/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import ChatClient from '@/app/chat/[identifier]/chat'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat',
|
||||
}
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
|
||||
const { identifier } = await params
|
||||
return <ChatClient identifier={identifier} />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,5 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Form from '@/app/form/[identifier]/form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Form',
|
||||
}
|
||||
|
||||
export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
|
||||
const { identifier } = await params
|
||||
return <Form identifier={identifier} />
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Invite from '@/app/invite/[id]/invite'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Invite',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export default Invite
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Resume Execution',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import Templates from '@/app/templates/templates'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
description:
|
||||
'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Public templates list page.
|
||||
* Redirects authenticated users to their workspace-scoped templates page.
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Unsubscribe from '@/app/unsubscribe/unsubscribe'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Unsubscribe',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export default Unsubscribe
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ResourceOptionsBar({
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='flex flex-1 items-center'>
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search.value}
|
||||
@@ -45,13 +45,13 @@ export function ResourceOptionsBar({
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{onFilter && (
|
||||
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onFilter}>
|
||||
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
|
||||
<ListFilter className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</Button>
|
||||
)}
|
||||
{onSort && (
|
||||
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
|
||||
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
|
||||
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function Resource({
|
||||
>
|
||||
{col.header}
|
||||
{sort.column === col.id && (
|
||||
<SortIcon className='ml-[4px] h-[12px] w-[12px]' />
|
||||
<SortIcon className='ml-[4px] h-[12px] w-[12px] text-[var(--text-icon)]' />
|
||||
)}
|
||||
</Button>
|
||||
</th>
|
||||
@@ -210,7 +210,7 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
|
||||
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{cell.icon && <span className='flex-shrink-0 text-[var(--text-subtle)]'>{cell.icon}</span>}
|
||||
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
|
||||
<span className='truncate'>{cell.label}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect, unstable_rethrow } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { FileViewer } from '@/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'File Viewer',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
interface FileViewerPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Files } from './files'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Files',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
interface FilesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { Home } from './home'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Home',
|
||||
}
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
|
||||
|
||||
interface DocumentPageProps {
|
||||
@@ -11,6 +12,13 @@ interface DocumentPageProps {
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: DocumentPageProps): Promise<Metadata> {
|
||||
const { docName, kbName } = await searchParams
|
||||
const title = docName || 'Document'
|
||||
const parentName = kbName || 'Knowledge Base'
|
||||
return { title: `${title} — ${parentName}` }
|
||||
}
|
||||
|
||||
export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) {
|
||||
const { id, documentId } = await params
|
||||
const { kbName, docName } = await searchParams
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
|
||||
|
||||
interface PageProps {
|
||||
@@ -9,6 +10,11 @@ interface PageProps {
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
|
||||
const { kbName } = await searchParams
|
||||
return { title: kbName || 'Knowledge Base' }
|
||||
}
|
||||
|
||||
export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
|
||||
const { id } = await params
|
||||
const { kbName } = await searchParams
|
||||
|
||||
@@ -100,10 +100,6 @@ export function Knowledge() {
|
||||
setIsCreateModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
await updateKnowledgeBaseMutation({
|
||||
@@ -208,8 +204,8 @@ export function Knowledge() {
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}}
|
||||
defaultSort='created'
|
||||
onSort={handleSort}
|
||||
onFilter={handleFilter}
|
||||
onSort={() => {}}
|
||||
onFilter={() => {}}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Knowledge Base',
|
||||
}
|
||||
|
||||
interface KnowledgePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full bg-[#F4F4F4] dark:bg-[var(--surface-1)]'>
|
||||
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Logs from '@/app/workspace/[workspaceId]/logs/logs'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Logs',
|
||||
}
|
||||
|
||||
export default Logs
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { Schedules } from './schedules'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Schedules',
|
||||
}
|
||||
|
||||
interface SchedulesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { SettingsPage } from './settings'
|
||||
|
||||
export default function SettingsSectionPage() {
|
||||
const params = useParams()
|
||||
const section = params.section as SettingsSection
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
general: 'General',
|
||||
credentials: 'Secrets',
|
||||
'template-profile': 'Template Profile',
|
||||
'access-control': 'Access Control',
|
||||
apikeys: 'Sim Keys',
|
||||
byok: 'BYOK',
|
||||
subscription: 'Subscription',
|
||||
team: 'Team',
|
||||
sso: 'Single Sign-On',
|
||||
copilot: 'Copilot Keys',
|
||||
mcp: 'MCP Tools',
|
||||
'custom-tools': 'Custom Tools',
|
||||
skills: 'Skills',
|
||||
'workflow-mcp-servers': 'MCP Servers',
|
||||
'credential-sets': 'Email Polling',
|
||||
debug: 'Debug',
|
||||
} as const
|
||||
|
||||
return <SettingsPage section={section} />
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ section: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { section } = await params
|
||||
return { title: SECTION_TITLES[section] ?? 'Settings' }
|
||||
}
|
||||
|
||||
export default async function SettingsSectionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string; section: string }>
|
||||
}) {
|
||||
const { section } = await params
|
||||
return <SettingsPage section={section as SettingsSection} />
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Checkbox, Skeleton } from '@/components/emcn'
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Plus,
|
||||
Table as TableIcon,
|
||||
TypeBoolean,
|
||||
TypeJson,
|
||||
@@ -14,17 +15,46 @@ import {
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
|
||||
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
|
||||
import { STRING_TRUNCATE_LENGTH } from '../../constants'
|
||||
import { useAddTableColumn, useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
|
||||
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
|
||||
import type { CellViewerData, EditingCell, QueryOptions } from '../../types'
|
||||
import type { EditingCell, QueryOptions } from '../../types'
|
||||
import { cleanCellValue, formatValueForInput } from '../../utils'
|
||||
import { CellViewerModal } from '../cell-viewer-modal'
|
||||
import { ContextMenu } from '../context-menu'
|
||||
import { RowModal } from '../row-modal'
|
||||
import { SchemaModal } from '../schema-modal'
|
||||
|
||||
type SelectedCell =
|
||||
| { kind: 'data'; rowId: string; columnName: string }
|
||||
| { kind: 'placeholder'; index: number; columnName: string }
|
||||
|
||||
interface PendingPlaceholder {
|
||||
rowId: string | null
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const EMPTY_COLUMNS: never[] = []
|
||||
const PLACEHOLDER_ROW_COUNT = 50
|
||||
const COL_WIDTH = 160
|
||||
const CHECKBOX_COL_WIDTH = 40
|
||||
const ADD_COL_WIDTH = 120
|
||||
|
||||
const CELL = 'border-[var(--border)] border-r border-b px-[8px] py-[7px] align-middle'
|
||||
const CELL_CHECKBOX = 'border-[var(--border)] border-r border-b px-[12px] py-[7px] align-middle'
|
||||
const CELL_HEADER =
|
||||
'border-[var(--border)] border-r border-b bg-white px-[8px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
|
||||
const CELL_HEADER_CHECKBOX =
|
||||
'border-[var(--border)] border-r border-b bg-white px-[12px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
|
||||
const CELL_CONTENT = 'relative min-h-[20px] min-w-0 overflow-hidden truncate text-[13px]'
|
||||
const SELECTION_OVERLAY =
|
||||
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px border-2 border-[var(--brand-tertiary-2)]'
|
||||
|
||||
const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
string: TypeText,
|
||||
number: TypeNumber,
|
||||
boolean: TypeBoolean,
|
||||
date: CalendarIcon,
|
||||
json: TypeJson,
|
||||
}
|
||||
|
||||
export function Table() {
|
||||
const params = useParams()
|
||||
@@ -44,15 +74,16 @@ export function Table() {
|
||||
const [deletingRows, setDeletingRows] = useState<string[]>([])
|
||||
const [showSchemaModal, setShowSchemaModal] = useState(false)
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
|
||||
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null)
|
||||
const [pendingPlaceholders, setPendingPlaceholders] = useState<
|
||||
Record<number, PendingPlaceholder>
|
||||
>({})
|
||||
|
||||
const [editingEmptyCell, setEditingEmptyCell] = useState<{
|
||||
rowIndex: number
|
||||
columnName: string
|
||||
} | null>(null)
|
||||
|
||||
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
@@ -70,17 +101,34 @@ export function Table() {
|
||||
|
||||
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
|
||||
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
|
||||
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
|
||||
|
||||
const columns = useMemo(
|
||||
() => tableData?.schema?.columns || EMPTY_COLUMNS,
|
||||
[tableData?.schema?.columns]
|
||||
)
|
||||
|
||||
const pendingRowIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const pending of Object.values(pendingPlaceholders)) {
|
||||
if (pending.rowId) ids.add(pending.rowId)
|
||||
}
|
||||
return ids
|
||||
}, [pendingPlaceholders])
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => rows.filter((r) => !pendingRowIds.has(r.id)),
|
||||
[rows, pendingRowIds]
|
||||
)
|
||||
|
||||
const tableWidth = CHECKBOX_COL_WIDTH + columns.length * COL_WIDTH + ADD_COL_WIDTH
|
||||
const selectedCount = selectedRows.size
|
||||
const hasSelection = selectedCount > 0
|
||||
const isAllSelected = rows.length > 0 && selectedCount === rows.length
|
||||
const isAllSelected = visibleRows.length > 0 && selectedCount === visibleRows.length
|
||||
|
||||
const columnsRef = useRef(columns)
|
||||
const rowsRef = useRef(rows)
|
||||
const pendingPlaceholdersRef = useRef(pendingPlaceholders)
|
||||
|
||||
useEffect(() => {
|
||||
columnsRef.current = columns
|
||||
@@ -88,6 +136,9 @@ export function Table() {
|
||||
useEffect(() => {
|
||||
rowsRef.current = rows
|
||||
}, [rows])
|
||||
useEffect(() => {
|
||||
pendingPlaceholdersRef.current = pendingPlaceholders
|
||||
}, [pendingPlaceholders])
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/tables`)
|
||||
@@ -97,10 +148,6 @@ export function Table() {
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
setDeletingRows(Array.from(selectedRows))
|
||||
}, [selectedRows])
|
||||
@@ -127,30 +174,17 @@ export function Table() {
|
||||
[baseHandleRowContextMenu]
|
||||
)
|
||||
|
||||
const handleCopyCellValue = useCallback(async () => {
|
||||
if (cellViewer) {
|
||||
let text: string
|
||||
if (cellViewer.type === 'json') {
|
||||
text = JSON.stringify(cellViewer.value, null, 2)
|
||||
} else if (cellViewer.type === 'date') {
|
||||
text = String(cellViewer.value)
|
||||
} else {
|
||||
text = String(cellViewer.value)
|
||||
}
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [cellViewer])
|
||||
const handleSelectCell = useCallback((rowId: string, columnName: string) => {
|
||||
setSelectedCell({ kind: 'data', rowId, columnName })
|
||||
}, [])
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(columnName: string, value: unknown, type: CellViewerData['type']) => {
|
||||
setCellViewer({ columnName, value, type })
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleSelectPlaceholderCell = useCallback((index: number, columnName: string) => {
|
||||
setSelectedCell({ kind: 'placeholder', index, columnName })
|
||||
}, [])
|
||||
|
||||
const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => {
|
||||
setSelectedCell({ kind: 'data', rowId, columnName })
|
||||
|
||||
const column = columnsRef.current.find((c) => c.name === columnName)
|
||||
if (!column) return
|
||||
|
||||
@@ -160,7 +194,13 @@ export function Table() {
|
||||
return
|
||||
}
|
||||
|
||||
if (column.type === 'boolean') return
|
||||
if (column.type === 'boolean') {
|
||||
const row = rowsRef.current.find((r) => r.id === rowId)
|
||||
if (row) {
|
||||
mutateRef.current({ rowId, data: { [columnName]: !row.data[columnName] } })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setEditingCell({ rowId, columnName })
|
||||
}, [])
|
||||
@@ -170,6 +210,60 @@ export function Table() {
|
||||
mutateRef.current = updateRowMutation.mutate
|
||||
}, [updateRowMutation.mutate])
|
||||
|
||||
const selectedCellRef = useRef(selectedCell)
|
||||
useEffect(() => {
|
||||
selectedCellRef.current = selectedCell
|
||||
}, [selectedCell])
|
||||
|
||||
const editingCellRef = useRef(editingCell)
|
||||
useEffect(() => {
|
||||
editingCellRef.current = editingCell
|
||||
}, [editingCell])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const sel = selectedCellRef.current
|
||||
if (!sel || sel.kind !== 'data' || editingCellRef.current) return
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
mutateRef.current({ rowId: sel.rowId, data: { [sel.columnName]: null } })
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
|
||||
e.preventDefault()
|
||||
const row = rowsRef.current.find((r) => r.id === sel.rowId)
|
||||
if (row) {
|
||||
const value = row.data[sel.columnName]
|
||||
let text = ''
|
||||
if (value !== null && value !== undefined) {
|
||||
text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
|
||||
e.preventDefault()
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const current = selectedCellRef.current
|
||||
if (!current || current.kind !== 'data') return
|
||||
const column = columnsRef.current.find((c) => c.name === current.columnName)
|
||||
if (!column) return
|
||||
try {
|
||||
const cleaned = cleanCellValue(text, column)
|
||||
mutateRef.current({ rowId: current.rowId, data: { [current.columnName]: cleaned } })
|
||||
} catch {
|
||||
// ignore invalid paste values
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const handleInlineSave = useCallback((rowId: string, columnName: string, value: unknown) => {
|
||||
setEditingCell(null)
|
||||
|
||||
@@ -187,13 +281,6 @@ export function Table() {
|
||||
setEditingCell(null)
|
||||
}, [])
|
||||
|
||||
const handleBooleanToggle = useCallback(
|
||||
(rowId: string, columnName: string, currentValue: boolean) => {
|
||||
mutateRef.current({ rowId, data: { [columnName]: !currentValue } })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => {
|
||||
const column = columnsRef.current.find((c) => c.name === columnName)
|
||||
if (!column || column.type === 'json' || column.type === 'boolean') return
|
||||
@@ -205,16 +292,65 @@ export function Table() {
|
||||
createRef.current = createRowMutation.mutate
|
||||
}, [createRowMutation.mutate])
|
||||
|
||||
const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => {
|
||||
const handleEmptyRowSave = useCallback((rowIndex: number, columnName: string, value: unknown) => {
|
||||
setEditingEmptyCell(null)
|
||||
if (value === null || value === undefined || value === '') return
|
||||
createRef.current({ [columnName]: value })
|
||||
|
||||
const existing = pendingPlaceholdersRef.current[rowIndex]
|
||||
const updatedData = { ...(existing?.data || {}), [columnName]: value }
|
||||
|
||||
if (existing?.rowId) {
|
||||
setPendingPlaceholders((prev) => ({
|
||||
...prev,
|
||||
[rowIndex]: { ...prev[rowIndex], data: updatedData },
|
||||
}))
|
||||
mutateRef.current({ rowId: existing.rowId, data: { [columnName]: value } })
|
||||
} else {
|
||||
setPendingPlaceholders((prev) => ({
|
||||
...prev,
|
||||
[rowIndex]: { rowId: null, data: updatedData },
|
||||
}))
|
||||
createRef.current(updatedData, {
|
||||
onSuccess: (response: Record<string, unknown>) => {
|
||||
const data = response?.data as Record<string, unknown> | undefined
|
||||
const row = data?.row as Record<string, unknown> | undefined
|
||||
const newRowId = row?.id as string | undefined
|
||||
if (newRowId) {
|
||||
setPendingPlaceholders((prev) => {
|
||||
if (!prev[rowIndex]) return prev
|
||||
return {
|
||||
...prev,
|
||||
[rowIndex]: { ...prev[rowIndex], rowId: newRowId },
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setPendingPlaceholders((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[rowIndex]
|
||||
return next
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEmptyRowCancel = useCallback(() => {
|
||||
setEditingEmptyCell(null)
|
||||
}, [])
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
|
||||
let name = 'untitled'
|
||||
let i = 2
|
||||
while (existing.includes(name.toLowerCase())) {
|
||||
name = `untitled_${i}`
|
||||
i++
|
||||
}
|
||||
addColumnMutation.mutate({ name, type: 'string' })
|
||||
}, [addColumnMutation])
|
||||
|
||||
if (isLoadingTable) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
@@ -238,34 +374,42 @@ export function Table() {
|
||||
breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]}
|
||||
/>
|
||||
|
||||
<ResourceOptionsBar onSort={handleSort} onFilter={handleFilter} />
|
||||
<ResourceOptionsBar onSort={() => {}} onFilter={() => {}} />
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<table className='border-collapse text-[13px]'>
|
||||
<colgroup>
|
||||
<col className='w-[40px]' />
|
||||
{columns.map((col) => (
|
||||
<col key={col.name} className='w-[160px]' />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className='sticky top-0 z-10 bg-white shadow-[inset_0_-1px_0_var(--border)] dark:bg-[var(--bg)]'>
|
||||
<table
|
||||
className='table-fixed border-separate border-spacing-0 text-[13px]'
|
||||
style={{ width: `${tableWidth}px` }}
|
||||
>
|
||||
<TableColGroup columns={columns} />
|
||||
<thead className='sticky top-0 z-10'>
|
||||
<tr>
|
||||
<th className='border-[var(--border)] border-r py-[10px] pr-[12px] pl-[24px] text-left align-middle'>
|
||||
<th className={CELL_HEADER_CHECKBOX}>
|
||||
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
</th>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.name}
|
||||
className='border-[var(--border)] border-r px-[24px] py-[10px] text-left align-middle'
|
||||
>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<th key={column.name} className={CELL_HEADER}>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
<span className='min-w-0 truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{column.name}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className={CELL_HEADER}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex cursor-pointer items-center gap-[8px]'
|
||||
onClick={handleAddColumn}
|
||||
disabled={addColumnMutation.isPending}
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Add column
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -273,7 +417,7 @@ export function Table() {
|
||||
<TableSkeleton columns={columns} />
|
||||
) : (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
{visibleRows.map((row) => (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
@@ -282,21 +426,28 @@ export function Table() {
|
||||
editingColumnName={
|
||||
editingCell?.rowId === row.id ? editingCell.columnName : null
|
||||
}
|
||||
onCellClick={handleCellClick}
|
||||
selectedColumnName={
|
||||
selectedCell?.kind === 'data' && selectedCell.rowId === row.id
|
||||
? selectedCell.columnName
|
||||
: null
|
||||
}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
onSave={handleInlineSave}
|
||||
onCancel={handleInlineCancel}
|
||||
onBooleanToggle={handleBooleanToggle}
|
||||
onContextMenu={handleRowContextMenu}
|
||||
onSelectRow={handleSelectRow}
|
||||
onSelectCell={handleSelectCell}
|
||||
/>
|
||||
))}
|
||||
<PlaceholderRows
|
||||
columns={columns}
|
||||
editingEmptyCell={editingEmptyCell}
|
||||
pendingPlaceholders={pendingPlaceholders}
|
||||
selectedCell={selectedCell}
|
||||
onDoubleClick={handleEmptyRowDoubleClick}
|
||||
onSave={handleEmptyRowSave}
|
||||
onCancel={handleEmptyRowCancel}
|
||||
onSelectCell={handleSelectPlaceholderCell}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -318,9 +469,7 @@ export function Table() {
|
||||
isOpen={true}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
table={tableData}
|
||||
onSuccess={() => {
|
||||
setShowAddModal(false)
|
||||
}}
|
||||
onSuccess={() => setShowAddModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -331,9 +480,7 @@ export function Table() {
|
||||
onClose={() => setEditingRow(null)}
|
||||
table={tableData}
|
||||
row={editingRow}
|
||||
onSuccess={() => {
|
||||
setEditingRow(null)
|
||||
}}
|
||||
onSuccess={() => setEditingRow(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -358,13 +505,6 @@ export function Table() {
|
||||
tableName={tableData.name}
|
||||
/>
|
||||
|
||||
<CellViewerModal
|
||||
cellViewer={cellViewer}
|
||||
onClose={() => setCellViewer(null)}
|
||||
onCopy={handleCopyCellValue}
|
||||
copied={copied}
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
contextMenu={contextMenu}
|
||||
onClose={closeContextMenu}
|
||||
@@ -375,60 +515,77 @@ export function Table() {
|
||||
)
|
||||
}
|
||||
|
||||
function TableColGroup({ columns }: { columns: ColumnDefinition[] }) {
|
||||
return (
|
||||
<colgroup>
|
||||
<col style={{ width: CHECKBOX_COL_WIDTH }} />
|
||||
{columns.map((col) => (
|
||||
<col key={col.name} style={{ width: COL_WIDTH }} />
|
||||
))}
|
||||
<col style={{ width: ADD_COL_WIDTH }} />
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionOverlay() {
|
||||
return <div className={SELECTION_OVERLAY} />
|
||||
}
|
||||
|
||||
const DataRow = React.memo(function DataRow({
|
||||
row,
|
||||
columns,
|
||||
isSelected,
|
||||
editingColumnName,
|
||||
onCellClick,
|
||||
selectedColumnName,
|
||||
onDoubleClick,
|
||||
onSave,
|
||||
onCancel,
|
||||
onBooleanToggle,
|
||||
onContextMenu,
|
||||
onSelectRow,
|
||||
onSelectCell,
|
||||
}: {
|
||||
row: TableRowType
|
||||
columns: ColumnDefinition[]
|
||||
isSelected: boolean
|
||||
editingColumnName: string | null
|
||||
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
|
||||
selectedColumnName: string | null
|
||||
onDoubleClick: (rowId: string, columnName: string) => void
|
||||
onSave: (rowId: string, columnName: string, value: unknown) => void
|
||||
onCancel: () => void
|
||||
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
|
||||
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
|
||||
onSelectRow: (rowId: string) => void
|
||||
onSelectCell: (rowId: string, columnName: string) => void
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={cn('group', isSelected && 'bg-[var(--surface-5)]')}
|
||||
onContextMenu={(e) => onContextMenu(e, row)}
|
||||
>
|
||||
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
|
||||
<td className={CELL_CHECKBOX}>
|
||||
<Checkbox size='sm' checked={isSelected} onCheckedChange={() => onSelectRow(row.id)} />
|
||||
</td>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.name}
|
||||
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
|
||||
>
|
||||
<div className='max-w-[300px] truncate text-[13px]'>
|
||||
<CellContent
|
||||
value={row.data[column.name]}
|
||||
column={column}
|
||||
isEditing={editingColumnName === column.name}
|
||||
onCellClick={onCellClick}
|
||||
onDoubleClick={() => onDoubleClick(row.id, column.name)}
|
||||
onSave={(value) => onSave(row.id, column.name, value)}
|
||||
onCancel={onCancel}
|
||||
onBooleanToggle={() =>
|
||||
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
{columns.map((column) => {
|
||||
const isColumnSelected = selectedColumnName === column.name
|
||||
return (
|
||||
<td
|
||||
key={column.name}
|
||||
className={cn(CELL, isColumnSelected && 'relative z-[11]')}
|
||||
onClick={() => onSelectCell(row.id, column.name)}
|
||||
onDoubleClick={() => onDoubleClick(row.id, column.name)}
|
||||
>
|
||||
{isColumnSelected && <SelectionOverlay />}
|
||||
<div className={CELL_CONTENT}>
|
||||
<CellContent
|
||||
value={row.data[column.name]}
|
||||
column={column}
|
||||
isEditing={editingColumnName === column.name}
|
||||
onSave={(value) => onSave(row.id, column.name, value)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
@@ -437,20 +594,14 @@ function CellContent({
|
||||
value,
|
||||
column,
|
||||
isEditing,
|
||||
onCellClick,
|
||||
onDoubleClick,
|
||||
onSave,
|
||||
onCancel,
|
||||
onBooleanToggle,
|
||||
}: {
|
||||
value: unknown
|
||||
column: ColumnDefinition
|
||||
isEditing: boolean
|
||||
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
|
||||
onDoubleClick: () => void
|
||||
onSave: (value: unknown) => void
|
||||
onCancel: () => void
|
||||
onBooleanToggle: () => void
|
||||
}) {
|
||||
if (isEditing) {
|
||||
return <InlineEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
|
||||
@@ -461,75 +612,25 @@ function CellContent({
|
||||
if (column.type === 'boolean') {
|
||||
const boolValue = Boolean(value)
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer select-none'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onBooleanToggle()
|
||||
}}
|
||||
>
|
||||
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
|
||||
{isNull ? (
|
||||
<span className='text-[var(--text-muted)] italic'>—</span>
|
||||
) : boolValue ? (
|
||||
'true'
|
||||
) : (
|
||||
'false'
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isNull) {
|
||||
return (
|
||||
<span
|
||||
className='cursor-text text-[var(--text-muted)] italic'
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
>
|
||||
—
|
||||
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
|
||||
{isNull ? '' : boolValue ? 'true' : 'false'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isNull) return null
|
||||
|
||||
if (column.type === 'json') {
|
||||
const jsonStr = JSON.stringify(value)
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCellClick(column.name, value, 'json')
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
title='Click to view, double-click to edit'
|
||||
>
|
||||
{jsonStr}
|
||||
</button>
|
||||
<span className='block truncate font-mono text-[11px] text-[var(--text-secondary)]'>
|
||||
{JSON.stringify(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.type === 'number') {
|
||||
return (
|
||||
<span
|
||||
className='cursor-text font-mono text-[12px] text-[var(--text-secondary)]'
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,66 +644,13 @@ function CellContent({
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return (
|
||||
<span
|
||||
className='cursor-text text-[12px] text-[var(--text-secondary)]'
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
)
|
||||
return <span className='text-[12px] text-[var(--text-secondary)]'>{formatted}</span>
|
||||
} catch {
|
||||
return (
|
||||
<span
|
||||
className='cursor-text text-[var(--text-primary)]'
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
)
|
||||
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
|
||||
}
|
||||
}
|
||||
|
||||
const strValue = String(value)
|
||||
if (strValue.length > STRING_TRUNCATE_LENGTH) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCellClick(column.name, value, 'text')
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
title='Click to view, double-click to edit'
|
||||
>
|
||||
{strValue}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className='cursor-text text-[var(--text-primary)]'
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDoubleClick()
|
||||
}}
|
||||
>
|
||||
{strValue}
|
||||
</span>
|
||||
)
|
||||
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
|
||||
}
|
||||
|
||||
function InlineEditor({
|
||||
@@ -631,10 +679,8 @@ function InlineEditor({
|
||||
const handleSave = () => {
|
||||
if (doneRef.current) return
|
||||
doneRef.current = true
|
||||
|
||||
try {
|
||||
const cleaned = cleanCellValue(draft, column)
|
||||
onSave(cleaned)
|
||||
onSave(cleanCellValue(draft, column))
|
||||
} catch {
|
||||
onCancel()
|
||||
}
|
||||
@@ -661,7 +707,14 @@ function InlineEditor({
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset'
|
||||
className={cn(
|
||||
'h-full w-full min-w-0 border-none bg-transparent p-0 outline-none',
|
||||
column.type === 'number'
|
||||
? 'font-mono text-[12px] text-[var(--text-secondary)]'
|
||||
: column.type === 'date'
|
||||
? 'text-[12px] text-[var(--text-secondary)]'
|
||||
: 'text-[13px] text-[var(--text-primary)]'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -708,7 +761,7 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
|
||||
<>
|
||||
{Array.from({ length: 25 }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
|
||||
<td className={CELL_CHECKBOX}>
|
||||
<Skeleton className='h-[14px] w-[14px]' />
|
||||
</td>
|
||||
{columns.map((col, colIndex) => {
|
||||
@@ -724,14 +777,10 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
|
||||
: col.type === 'date'
|
||||
? 100
|
||||
: 120
|
||||
const variation = ((rowIndex + colIndex) % 3) * 20
|
||||
const width = baseWidth + variation
|
||||
const width = baseWidth + ((rowIndex + colIndex) % 3) * 20
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
|
||||
>
|
||||
<td key={col.name} className={CELL}>
|
||||
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
|
||||
</td>
|
||||
)
|
||||
@@ -742,64 +791,81 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ROW_COUNT = 50
|
||||
|
||||
function PlaceholderRows({
|
||||
columns,
|
||||
editingEmptyCell,
|
||||
pendingPlaceholders,
|
||||
selectedCell,
|
||||
onDoubleClick,
|
||||
onSave,
|
||||
onCancel,
|
||||
onSelectCell,
|
||||
}: {
|
||||
columns: ColumnDefinition[]
|
||||
editingEmptyCell: { rowIndex: number; columnName: string } | null
|
||||
pendingPlaceholders: Record<number, PendingPlaceholder>
|
||||
selectedCell: SelectedCell | null
|
||||
onDoubleClick: (rowIndex: number, columnName: string) => void
|
||||
onSave: (columnName: string, value: unknown) => void
|
||||
onSave: (rowIndex: number, columnName: string, value: unknown) => void
|
||||
onCancel: () => void
|
||||
onSelectCell: (index: number, columnName: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => (
|
||||
<tr key={`placeholder-${i}`}>
|
||||
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle' />
|
||||
{columns.map((col) => {
|
||||
const isEditing =
|
||||
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
|
||||
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => {
|
||||
const pending = pendingPlaceholders[i]
|
||||
return (
|
||||
<tr key={`placeholder-${i}`}>
|
||||
<td className={CELL_CHECKBOX} />
|
||||
{columns.map((col) => {
|
||||
const isEditing =
|
||||
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
|
||||
const pendingValue = pending?.data[col.name]
|
||||
const hasPendingValue = pendingValue !== undefined && pendingValue !== null
|
||||
const isColumnSelected =
|
||||
selectedCell?.kind === 'placeholder' &&
|
||||
selectedCell.index === i &&
|
||||
selectedCell.columnName === col.name
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
|
||||
onDoubleClick={() => onDoubleClick(i, col.name)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<InlineEditor
|
||||
value={null}
|
||||
column={col}
|
||||
onSave={(value) => onSave(col.name, value)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : (
|
||||
<div className='min-h-[20px]' />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className={cn(CELL, isColumnSelected && 'relative z-[11]')}
|
||||
onClick={() => onSelectCell(i, col.name)}
|
||||
onDoubleClick={() => onDoubleClick(i, col.name)}
|
||||
>
|
||||
{isColumnSelected && <SelectionOverlay />}
|
||||
{isEditing ? (
|
||||
<InlineEditor
|
||||
value={hasPendingValue ? pendingValue : null}
|
||||
column={col}
|
||||
onSave={(value) => onSave(i, col.name, value)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : hasPendingValue ? (
|
||||
<div className={CELL_CONTENT}>
|
||||
<CellContent
|
||||
value={pendingValue}
|
||||
column={col}
|
||||
isEditing={false}
|
||||
onSave={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-[20px]' />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
string: TypeText,
|
||||
number: TypeNumber,
|
||||
boolean: TypeBoolean,
|
||||
date: CalendarIcon,
|
||||
json: TypeJson,
|
||||
}
|
||||
|
||||
function ColumnTypeIcon({ type }: { type: string }) {
|
||||
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
|
||||
return <Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
return <Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Table } from './components'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Table',
|
||||
}
|
||||
|
||||
export default function TablePage() {
|
||||
return <Table />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CreateModal } from './create-modal'
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './create-modal'
|
||||
export * from './empty-state'
|
||||
export * from './error-state'
|
||||
export * from './loading-state'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Tables } from './tables'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tables',
|
||||
}
|
||||
|
||||
interface TablesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -6,14 +6,15 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { generateUniqueTableName } from '@/lib/table/constants'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
|
||||
import { CreateModal, TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
|
||||
import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
|
||||
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDeleteTable, useTablesList } from '@/hooks/queries/tables'
|
||||
import { useCreateTable, useDeleteTable, useTablesList } from '@/hooks/queries/tables'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('Tables')
|
||||
@@ -40,8 +41,8 @@ export function Tables() {
|
||||
logger.error('Failed to load tables:', error)
|
||||
}
|
||||
const deleteTable = useDeleteTable(workspaceId)
|
||||
const createTable = useCreateTable(workspaceId)
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
@@ -100,10 +101,6 @@ export function Tables() {
|
||||
[filteredTables, members]
|
||||
)
|
||||
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -147,6 +144,23 @@ export function Tables() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTable = useCallback(async () => {
|
||||
const existingNames = tables.map((t) => t.name)
|
||||
const name = generateUniqueTableName(existingNames)
|
||||
try {
|
||||
const result = await createTable.mutateAsync({
|
||||
name,
|
||||
schema: { columns: [{ name: 'name', type: 'string' }] },
|
||||
})
|
||||
const tableId = result?.data?.table?.id
|
||||
if (tableId) {
|
||||
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to create table:', err)
|
||||
}
|
||||
}, [tables, createTable, router, workspaceId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
@@ -154,8 +168,8 @@ export function Tables() {
|
||||
title='Tables'
|
||||
create={{
|
||||
label: 'New table',
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
onClick: handleCreateTable,
|
||||
disabled: userPermissions.canEdit !== true || createTable.isPending,
|
||||
}}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
@@ -163,8 +177,8 @@ export function Tables() {
|
||||
placeholder: 'Search tables...',
|
||||
}}
|
||||
defaultSort='created'
|
||||
onSort={handleSort}
|
||||
onFilter={handleFilter}
|
||||
onSort={() => {}}
|
||||
onFilter={() => {}}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
@@ -178,8 +192,8 @@ export function Tables() {
|
||||
position={listContextMenuPosition}
|
||||
menuRef={listMenuRef}
|
||||
onClose={closeListContextMenu}
|
||||
onCreateTable={() => setIsCreateModalOpen(true)}
|
||||
disableCreate={userPermissions.canEdit !== true}
|
||||
onCreateTable={handleCreateTable}
|
||||
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
|
||||
/>
|
||||
|
||||
<TableContextMenu
|
||||
@@ -235,8 +249,6 @@ export function Tables() {
|
||||
tableName={activeTable.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Home } from '@/app/workspace/[workspaceId]/home/home'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Task',
|
||||
}
|
||||
|
||||
interface TaskPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -8,6 +9,10 @@ import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
}
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workflow',
|
||||
}
|
||||
|
||||
export default Workflow
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -37,6 +37,23 @@ export {
|
||||
type ComboboxOptionGroup,
|
||||
} from './combobox/combobox'
|
||||
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker'
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from './dropdown-menu/dropdown-menu'
|
||||
export { Input, type InputProps, inputVariants } from './input/input'
|
||||
export { Label } from './label/label'
|
||||
export {
|
||||
|
||||
@@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000200] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -56,15 +56,13 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, avoidCollisions = false, sticky = 'always', ...props }, ref) => (
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
sticky={sticky as any}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10000200] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -82,7 +80,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
@@ -98,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -121,7 +119,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -86,6 +86,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: [
|
||||
...(brand.faviconUrl ? [] : [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }]),
|
||||
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{
|
||||
@@ -98,10 +99,10 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{ url: brand.faviconUrl || '/sim.png', sizes: 'any', type: 'image/png' },
|
||||
...(brand.faviconUrl ? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }] : []),
|
||||
],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
shortcut: brand.faviconUrl || '/favicon/favicon.ico',
|
||||
shortcut: brand.faviconUrl || '/icon.svg',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { BrandConfig } from './types'
|
||||
export const defaultBrandConfig: BrandConfig = {
|
||||
name: 'Sim',
|
||||
logoUrl: undefined,
|
||||
faviconUrl: '/favicon/favicon.ico',
|
||||
faviconUrl: undefined,
|
||||
customCssUrl: undefined,
|
||||
supportEmail: 'help@sim.ai',
|
||||
documentationUrl: undefined,
|
||||
|
||||
@@ -57,3 +57,221 @@ export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as c
|
||||
export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i
|
||||
|
||||
export const USER_TABLE_ROWS_SQL_NAME = 'user_table_rows'
|
||||
|
||||
const TABLE_NAME_ADJECTIVES = [
|
||||
'Radiant',
|
||||
'Luminous',
|
||||
'Blazing',
|
||||
'Glowing',
|
||||
'Bright',
|
||||
'Gleaming',
|
||||
'Shining',
|
||||
'Lustrous',
|
||||
'Vivid',
|
||||
'Dazzling',
|
||||
'Stellar',
|
||||
'Cosmic',
|
||||
'Astral',
|
||||
'Galactic',
|
||||
'Nebular',
|
||||
'Orbital',
|
||||
'Lunar',
|
||||
'Solar',
|
||||
'Starlit',
|
||||
'Celestial',
|
||||
'Infinite',
|
||||
'Vast',
|
||||
'Boundless',
|
||||
'Immense',
|
||||
'Colossal',
|
||||
'Titanic',
|
||||
'Grand',
|
||||
'Supreme',
|
||||
'Eternal',
|
||||
'Ancient',
|
||||
'Timeless',
|
||||
'Primal',
|
||||
'Nascent',
|
||||
'Elder',
|
||||
'Swift',
|
||||
'Drifting',
|
||||
'Surging',
|
||||
'Pulsing',
|
||||
'Soaring',
|
||||
'Rising',
|
||||
'Spiraling',
|
||||
'Crimson',
|
||||
'Azure',
|
||||
'Violet',
|
||||
'Indigo',
|
||||
'Amber',
|
||||
'Sapphire',
|
||||
'Obsidian',
|
||||
'Silver',
|
||||
'Golden',
|
||||
'Scarlet',
|
||||
'Cobalt',
|
||||
'Emerald',
|
||||
'Magnetic',
|
||||
'Quantum',
|
||||
'Photonic',
|
||||
'Spectral',
|
||||
'Charged',
|
||||
'Atomic',
|
||||
'Electric',
|
||||
'Kinetic',
|
||||
'Ethereal',
|
||||
'Mystic',
|
||||
'Phantom',
|
||||
'Silent',
|
||||
'Distant',
|
||||
'Hidden',
|
||||
'Arcane',
|
||||
'Frozen',
|
||||
'Burning',
|
||||
'Molten',
|
||||
'Volatile',
|
||||
'Fiery',
|
||||
'Searing',
|
||||
'Frigid',
|
||||
'Mighty',
|
||||
'Fierce',
|
||||
'Serene',
|
||||
'Tranquil',
|
||||
'Harmonic',
|
||||
'Resonant',
|
||||
'Bold',
|
||||
'Noble',
|
||||
'Pure',
|
||||
'Rare',
|
||||
'Pristine',
|
||||
'Exotic',
|
||||
'Divine',
|
||||
] as const
|
||||
|
||||
const TABLE_NAME_NOUNS = [
|
||||
'Star',
|
||||
'Pulsar',
|
||||
'Quasar',
|
||||
'Magnetar',
|
||||
'Nova',
|
||||
'Supernova',
|
||||
'Neutron',
|
||||
'Protostar',
|
||||
'Blazar',
|
||||
'Cepheid',
|
||||
'Galaxy',
|
||||
'Nebula',
|
||||
'Cluster',
|
||||
'Void',
|
||||
'Filament',
|
||||
'Halo',
|
||||
'Spiral',
|
||||
'Remnant',
|
||||
'Cloud',
|
||||
'Planet',
|
||||
'Moon',
|
||||
'World',
|
||||
'Exoplanet',
|
||||
'Titan',
|
||||
'Europa',
|
||||
'Triton',
|
||||
'Enceladus',
|
||||
'Comet',
|
||||
'Meteor',
|
||||
'Asteroid',
|
||||
'Fireball',
|
||||
'Shard',
|
||||
'Fragment',
|
||||
'Orion',
|
||||
'Andromeda',
|
||||
'Perseus',
|
||||
'Pegasus',
|
||||
'Phoenix',
|
||||
'Draco',
|
||||
'Cygnus',
|
||||
'Aquila',
|
||||
'Lyra',
|
||||
'Vega',
|
||||
'Hydra',
|
||||
'Sirius',
|
||||
'Polaris',
|
||||
'Altair',
|
||||
'Eclipse',
|
||||
'Aurora',
|
||||
'Corona',
|
||||
'Flare',
|
||||
'Vortex',
|
||||
'Pulse',
|
||||
'Wave',
|
||||
'Ripple',
|
||||
'Shimmer',
|
||||
'Spark',
|
||||
'Horizon',
|
||||
'Zenith',
|
||||
'Apex',
|
||||
'Meridian',
|
||||
'Equinox',
|
||||
'Solstice',
|
||||
'Transit',
|
||||
'Orbit',
|
||||
'Cosmos',
|
||||
'Dimension',
|
||||
'Realm',
|
||||
'Expanse',
|
||||
'Infinity',
|
||||
'Continuum',
|
||||
'Abyss',
|
||||
'Ether',
|
||||
'Photon',
|
||||
'Neutrino',
|
||||
'Tachyon',
|
||||
'Graviton',
|
||||
'Sector',
|
||||
'Quadrant',
|
||||
'Belt',
|
||||
'Ring',
|
||||
'Field',
|
||||
'Stream',
|
||||
'Frontier',
|
||||
'Beacon',
|
||||
'Signal',
|
||||
'Probe',
|
||||
'Voyager',
|
||||
'Pioneer',
|
||||
'Sentinel',
|
||||
'Gateway',
|
||||
'Portal',
|
||||
'Nexus',
|
||||
'Conduit',
|
||||
'Rift',
|
||||
'Core',
|
||||
'Matrix',
|
||||
'Lattice',
|
||||
'Array',
|
||||
'Reactor',
|
||||
'Engine',
|
||||
'Forge',
|
||||
'Crucible',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Generates a unique space-themed table name that doesn't collide with existing names.
|
||||
* Uses lowercase with underscores to satisfy NAME_PATTERN validation.
|
||||
*/
|
||||
export function generateUniqueTableName(existingNames: string[]): string {
|
||||
const taken = new Set(existingNames.map((n) => n.toLowerCase()))
|
||||
const maxAttempts = 50
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
|
||||
const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
|
||||
const name = `${adj.toLowerCase()}_${noun.toLowerCase()}`
|
||||
if (!taken.has(name)) return name
|
||||
}
|
||||
|
||||
const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
|
||||
const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
|
||||
const suffix = Math.floor(Math.random() * 900) + 100
|
||||
return `${adj.toLowerCase()}_${noun.toLowerCase()}_${suffix}`
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -369,6 +369,10 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/favicon.ico',
|
||||
destination: '/icon.svg',
|
||||
},
|
||||
{
|
||||
source: '/r/:shortCode',
|
||||
destination: 'https://go.trybeluga.ai/:shortCode',
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
|
||||
11
apps/sim/public/icon.svg
Normal file
11
apps/sim/public/icon.svg
Normal 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 |
Reference in New Issue
Block a user