mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat: Add TDB block (#43490)
This commit is contained in:
1 parent
ac41f6bd6f
commit
98c0cea1c3
33 files changed
+2174
-15
No files matched your search
@@ -2,4 +2,5 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||
NEXT_PUBLIC_BASE_PATH=/ui
|
||||
NEXT_PUBLIC_API_URL="http://localhost:8080/platform"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:8080/platform"
|
||||
NEXT_PUBLIC_GOTRUE_URL="http://localhost:8000/auth/v1"
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider } from 'common'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
@@ -8,19 +9,24 @@ import { TooltipProvider } from 'ui'
|
||||
|
||||
import { FrameworkProvider } from '@/context/framework-context'
|
||||
import { MobileMenuProvider } from '@/context/mobile-menu-context'
|
||||
import { useRootQueryClient } from '@/lib/fetch/queryClient'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const queryClient = useRootQueryClient()
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<JotaiProvider>
|
||||
<NextThemesProvider {...props}>
|
||||
<MobileMenuProvider>
|
||||
<FrameworkProvider>
|
||||
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
|
||||
</FrameworkProvider>
|
||||
</MobileMenuProvider>
|
||||
</NextThemesProvider>
|
||||
</JotaiProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider>
|
||||
<NextThemesProvider {...props}>
|
||||
<MobileMenuProvider>
|
||||
<FrameworkProvider>
|
||||
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
|
||||
</FrameworkProvider>
|
||||
</MobileMenuProvider>
|
||||
</NextThemesProvider>
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { findPrimaryKeys, propAccess, sanitizeIdentifier, toCamelCase } from '../utils'
|
||||
|
||||
// Generate db.ts content from OpenAPI definitions
|
||||
export function generateDbContent(definitions: Record<string, OpenAPIDefinition>): string {
|
||||
const tableNames = Object.keys(definitions).filter((name) => !name.startsWith('_'))
|
||||
|
||||
const lines: string[] = [
|
||||
"import { supabaseCollectionOptions } from 'supa-tdb-collection'",
|
||||
"import { createClient } from '@/lib/supabase/client'",
|
||||
'import {',
|
||||
...tableNames.map((name) => ` ${toCamelCase(sanitizeIdentifier(name))}Schema,`),
|
||||
"} from './schemas'",
|
||||
"import { createCollection } from '@tanstack/db'",
|
||||
'',
|
||||
'const supabase = createClient()',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const [tableName, definition] of Object.entries(definitions)) {
|
||||
// Skip internal PostgREST tables
|
||||
if (tableName.startsWith('_')) continue
|
||||
|
||||
const safeTableId = sanitizeIdentifier(tableName)
|
||||
const collectionName = `${toCamelCase(safeTableId)}Collection`
|
||||
const schemaName = `${toCamelCase(safeTableId)}Schema`
|
||||
const properties = definition.properties || {}
|
||||
const primaryKeys = findPrimaryKeys(properties)
|
||||
|
||||
if (primaryKeys.length === 0) {
|
||||
throw new Error(
|
||||
`Table "${tableName}" has no primary key columns. TanStack DB collections require at least one primary key. Skipping generation for this table.`
|
||||
)
|
||||
}
|
||||
|
||||
// Generate getKey function using bracket notation for safety
|
||||
let getKeyFn: string
|
||||
if (primaryKeys.length === 1) {
|
||||
getKeyFn = `(item) => ${propAccess('item', primaryKeys[0])}`
|
||||
} else {
|
||||
const keyParts = primaryKeys.map((k) => `\${${propAccess('item', k)}}`).join('-')
|
||||
getKeyFn = `(item) => \`${keyParts}\``
|
||||
}
|
||||
|
||||
// Generate where function
|
||||
let whereFn: string
|
||||
if (primaryKeys.length === 1) {
|
||||
whereFn = `(query, item) => query.eq(${JSON.stringify(primaryKeys[0])}, ${propAccess('item', primaryKeys[0])})`
|
||||
} else {
|
||||
const whereParts = primaryKeys
|
||||
.map((k) => `.eq(${JSON.stringify(k)}, ${propAccess('item', k)})`)
|
||||
.join('')
|
||||
whereFn = `(query, item) => query${whereParts}`
|
||||
}
|
||||
|
||||
lines.push(`export const ${collectionName} = createCollection(supabaseCollectionOptions({`)
|
||||
lines.push(` tableName: ${JSON.stringify(tableName)},`)
|
||||
lines.push(` getKey: ${getKeyFn},`)
|
||||
lines.push(` where: ${whereFn},`)
|
||||
lines.push(` schema: ${schemaName},`)
|
||||
lines.push(` realtime: true,`)
|
||||
lines.push(' supabase: supabase,')
|
||||
lines.push('}))')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { generateSchemasContent } from './schemas'
|
||||
export { generateDbContent } from './db'
|
||||
export { generatePageContent } from './page'
|
||||
export { generateSheetContent } from './sheet'
|
||||
export { generateListContent } from './list'
|
||||
@@ -0,0 +1,206 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import {
|
||||
findPrimaryKeys,
|
||||
propAccess,
|
||||
sanitizeIdentifier,
|
||||
toCamelCase,
|
||||
toPascalCase,
|
||||
toSingular,
|
||||
} from '../utils'
|
||||
|
||||
// Generate list component content
|
||||
export function generateListContent(tableName: string, definition: OpenAPIDefinition): string {
|
||||
const safeTableId = sanitizeIdentifier(tableName)
|
||||
const pascalName = toPascalCase(safeTableId)
|
||||
const singularName = toSingular(safeTableId)
|
||||
const singularPascal = toPascalCase(singularName)
|
||||
const camelName = toCamelCase(safeTableId)
|
||||
const collectionName = `${camelName}Collection`
|
||||
const listComponentName = `${pascalName}List`
|
||||
const sheetComponentName = `${singularPascal}Sheet`
|
||||
const properties = definition.properties || {}
|
||||
const primaryKeys = findPrimaryKeys(properties)
|
||||
const primaryKey = primaryKeys[0] || 'id'
|
||||
|
||||
// Get display fields (non-system fields, first 2 for display)
|
||||
const editableFields = Object.entries(properties).filter(([name]) => {
|
||||
const isSystemField =
|
||||
name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
|
||||
return !isSystemField
|
||||
})
|
||||
const displayField = editableFields[0]?.[0] || primaryKey
|
||||
const secondaryField = editableFields[1]?.[0]
|
||||
|
||||
// Get all fields for select
|
||||
const allFieldNames = Object.keys(properties)
|
||||
const selectFields = allFieldNames
|
||||
.map((name) => ` ${JSON.stringify(name)}: ${propAccess('item', name)},`)
|
||||
.join('\n')
|
||||
|
||||
// Determine if we have created_at for display
|
||||
const hasCreatedAt = properties['created_at'] !== undefined
|
||||
|
||||
// Pre-compute safe property access expressions for the template
|
||||
const displayAccess = propAccess('item', displayField)
|
||||
const primaryKeyAccess = propAccess('item', primaryKey)
|
||||
|
||||
// Generate secondary info display
|
||||
let secondaryInfo = ''
|
||||
if (secondaryField) {
|
||||
const secondaryAccess = propAccess('item', secondaryField)
|
||||
secondaryInfo = `
|
||||
{${secondaryAccess} && (
|
||||
<span className="truncate">{${secondaryAccess}}</span>
|
||||
)}
|
||||
${hasCreatedAt ? `<span>•</span>` : ''}`
|
||||
}
|
||||
|
||||
return `'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { Pencil, Plus, Trash2, FileText } from 'lucide-react'
|
||||
|
||||
import { ${collectionName} } from '@/lib/db'
|
||||
import { ${pascalName} } from '@/lib/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ${sheetComponentName} } from './${singularName}-sheet'
|
||||
|
||||
export function ${listComponentName}() {
|
||||
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<${pascalName} | null>(null)
|
||||
|
||||
const { data: items = [] } = useLiveQuery(
|
||||
(q) =>
|
||||
q
|
||||
.from({ item: ${collectionName} })
|
||||
.orderBy(({ item }) => ${displayAccess}, 'asc')
|
||||
.select(({ item }) => ({
|
||||
${selectFields}
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDelete = (itemId: string) => {
|
||||
${collectionName}.delete([itemId])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-border/50 border-b bg-linear-to-r from-primary/5 to-transparent p-6 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<FileText className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl tracking-tight">${pascalName}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{items.length} ${singularName.replace(/_/g, ' ')}{items.length !== 1 ? 's' : ''} in database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="gap-2" onClick={() => setIsCreateSheetOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add ${singularPascal}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items list */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{items.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg p-4',
|
||||
'border border-border/50 bg-card shadow-sm',
|
||||
'transition-all duration-150 hover:shadow-md hover:border-border'
|
||||
)}
|
||||
key={${primaryKeyAccess}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-linear-to-br from-chart-1/20 to-chart-2/20 shrink-0">
|
||||
<FileText className="h-5 w-5 text-foreground/70" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{${displayAccess} || 'Untitled'}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-muted-foreground text-xs">${secondaryInfo}${
|
||||
hasCreatedAt
|
||||
? `
|
||||
<span>
|
||||
Created {new Date(item.created_at).toLocaleDateString()}
|
||||
</span>`
|
||||
: `
|
||||
<span className="font-mono truncate max-w-[120px]" title={${primaryKeyAccess}}>
|
||||
{${primaryKeyAccess}.slice(0, 8)}...
|
||||
</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={() => setEditingItem(item)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(${primaryKeyAccess})}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-muted-foreground border border-dashed border-border/50 rounded-lg">
|
||||
<FileText className="mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="font-medium">No ${tableName.replace(/_/g, ' ')} yet</p>
|
||||
<p className="text-sm mb-4">Create your first ${singularName.replace(/_/g, ' ')} to get started</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setIsCreateSheetOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add ${singularPascal}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Sheet */}
|
||||
<${sheetComponentName}
|
||||
open={isCreateSheetOpen}
|
||||
onOpenChange={setIsCreateSheetOpen}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
{/* Edit Sheet */}
|
||||
<${sheetComponentName}
|
||||
open={!!editingItem}
|
||||
onOpenChange={(open) => !open && setEditingItem(null)}
|
||||
mode="edit"
|
||||
item={editingItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { safeFileSegment, sanitizeIdentifier, toPascalCase } from '../utils'
|
||||
|
||||
// Generate page.tsx content
|
||||
export function generatePageContent(tableName: string): string {
|
||||
const safeTableId = sanitizeIdentifier(tableName)
|
||||
const pascalName = toPascalCase(safeTableId)
|
||||
const listComponentName = `${pascalName}List`
|
||||
const safeTable = safeFileSegment(tableName)
|
||||
|
||||
return `import { ${listComponentName} } from './${safeTable}-list'
|
||||
|
||||
export default async function ${pascalName}Page() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl py-8">
|
||||
<${listComponentName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { openApiTypeToZod, sanitizeIdentifier, toCamelCase, toPascalCase } from '../utils'
|
||||
|
||||
// Generate schemas.ts content from OpenAPI definitions
|
||||
export function generateSchemasContent(definitions: Record<string, OpenAPIDefinition>): string {
|
||||
const lines: string[] = ["import { z } from 'zod'", '']
|
||||
|
||||
for (const [tableName, definition] of Object.entries(definitions)) {
|
||||
// Skip internal PostgREST tables
|
||||
if (tableName.startsWith('_')) continue
|
||||
|
||||
const safeTableId = sanitizeIdentifier(tableName)
|
||||
const typeName = toPascalCase(safeTableId)
|
||||
const schemaName = `${toCamelCase(safeTableId)}Schema`
|
||||
const properties = definition.properties || {}
|
||||
const required = definition.required || []
|
||||
|
||||
lines.push(`// ${typeName} schema`)
|
||||
lines.push(`export const ${schemaName} = z.object({`)
|
||||
|
||||
for (const [propName, prop] of Object.entries(properties)) {
|
||||
const isRequired = required.includes(propName)
|
||||
const zodType = openApiTypeToZod(prop, isRequired)
|
||||
lines.push(` ${JSON.stringify(propName)}: ${zodType},`)
|
||||
}
|
||||
|
||||
lines.push('})')
|
||||
lines.push('')
|
||||
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import {
|
||||
findPrimaryKeys,
|
||||
propAccess,
|
||||
sanitizeIdentifier,
|
||||
toCamelCase,
|
||||
toLabel,
|
||||
toPascalCase,
|
||||
toSingular,
|
||||
} from '../utils'
|
||||
|
||||
// Generate sheet component content
|
||||
export function generateSheetContent(tableName: string, definition: OpenAPIDefinition): string {
|
||||
const safeTableId = sanitizeIdentifier(tableName)
|
||||
const pascalName = toPascalCase(safeTableId)
|
||||
const singularName = toSingular(safeTableId)
|
||||
const singularPascal = toPascalCase(singularName)
|
||||
const camelName = toCamelCase(safeTableId)
|
||||
const collectionName = `${camelName}Collection`
|
||||
const sheetComponentName = `${singularPascal}Sheet`
|
||||
const properties = definition.properties || {}
|
||||
const required = definition.required || []
|
||||
const primaryKeys = findPrimaryKeys(properties)
|
||||
const primaryKey = primaryKeys[0] || 'id'
|
||||
|
||||
// Separate fields into editable and read-only
|
||||
const allFields = Object.entries(properties)
|
||||
const editableFields = allFields.filter(([name]) => {
|
||||
const isSystemField =
|
||||
name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
|
||||
return !isSystemField
|
||||
})
|
||||
const readOnlyFields = allFields.filter(([name]) => {
|
||||
return (
|
||||
name === primaryKey || name === 'created_at' || name === 'updated_at' || name.endsWith('_at')
|
||||
)
|
||||
})
|
||||
|
||||
// Generate state declarations
|
||||
const stateDeclarations = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` const [${stateName}, set${toPascalCase(safeName)}] = useState(false)`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` const [${stateName}, set${toPascalCase(safeName)}] = useState<number | ''>(0)`
|
||||
}
|
||||
return ` const [${stateName}, set${toPascalCase(safeName)}] = useState('')`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate useEffect reset
|
||||
const useEffectReset = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
const setterName = `set${toPascalCase(safeName)}`
|
||||
const access = propAccess('item?', name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${setterName}(${access} ?? false)`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${setterName}(${access} ?? 0)`
|
||||
}
|
||||
return ` ${setterName}(${access} ?? '')`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate form fields for editable fields
|
||||
const editableFormFields = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
const setterName = `set${toPascalCase(safeName)}`
|
||||
const label = toLabel(name)
|
||||
const isRequired = required.includes(name)
|
||||
|
||||
if (prop.type === 'boolean') {
|
||||
return ` {/* ${label} Field */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="${singularName}-${name}"
|
||||
type="checkbox"
|
||||
checked={${stateName}}
|
||||
onChange={(e) => ${setterName}(e.target.checked)}
|
||||
className="h-4 w-4 rounded border"
|
||||
/>
|
||||
<Label htmlFor="${singularName}-${name}">${label}</Label>
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` {/* ${label} Field */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="${singularName}-${name}">
|
||||
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
|
||||
</Label>
|
||||
<Input
|
||||
id="${singularName}-${name}"
|
||||
type="number"
|
||||
value={${stateName}}
|
||||
onChange={(e) => ${setterName}(e.target.value ? Number(e.target.value) : '')}
|
||||
placeholder="Enter ${label.toLowerCase()}"
|
||||
/>
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (prop.enum && prop.enum.length > 0) {
|
||||
const options = prop.enum
|
||||
.map((e) => ` <option value="${e}">${toLabel(e)}</option>`)
|
||||
.join('\n')
|
||||
return ` {/* ${label} Field */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="${singularName}-${name}">
|
||||
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
|
||||
</Label>
|
||||
<select
|
||||
id="${singularName}-${name}"
|
||||
value={${stateName}}
|
||||
onChange={(e) => ${setterName}(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select ${label.toLowerCase()}</option>
|
||||
${options}
|
||||
</select>
|
||||
</div>`
|
||||
}
|
||||
|
||||
return ` {/* ${label} Field */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="${singularName}-${name}">
|
||||
${label}${isRequired ? ' <span className="text-destructive">*</span>' : ''}
|
||||
</Label>
|
||||
<Input
|
||||
id="${singularName}-${name}"
|
||||
value={${stateName}}
|
||||
onChange={(e) => ${setterName}(e.target.value)}
|
||||
placeholder="Enter ${label.toLowerCase()}"
|
||||
/>
|
||||
</div>`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Generate read-only fields for edit mode
|
||||
const readOnlyFormFields = readOnlyFields
|
||||
.map(([name]) => {
|
||||
const label = toLabel(name)
|
||||
const isTimestamp = name.endsWith('_at')
|
||||
const itemAccess = propAccess('item', name)
|
||||
const displayValue = isTimestamp ? `new Date(${itemAccess}).toLocaleString()` : itemAccess
|
||||
|
||||
return ` {/* ${label} - Read Only */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="${singularName}-${name}" className="text-muted-foreground">
|
||||
${label}
|
||||
</Label>
|
||||
<Input
|
||||
id="${singularName}-${name}"
|
||||
value={${displayValue}}
|
||||
disabled
|
||||
className="${name === primaryKey ? 'font-mono text-sm ' : ''}bg-muted"
|
||||
/>
|
||||
</div>`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Generate insert data
|
||||
const insertData = [
|
||||
` ${JSON.stringify(primaryKey)}: crypto.randomUUID(),`,
|
||||
...editableFields.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
const key = JSON.stringify(name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${key}: ${stateName},`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${key}: ${stateName} || 0,`
|
||||
}
|
||||
return ` ${key}: ${stateName},`
|
||||
}),
|
||||
// Add timestamps if they exist
|
||||
...readOnlyFields
|
||||
.filter(([name]) => name === 'created_at')
|
||||
.map(() => ` "created_at": new Date().toISOString(),`),
|
||||
].join('\n')
|
||||
|
||||
// Generate update data (direct mutations on draft)
|
||||
const updateData = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
const draftAccess = propAccess('draft', name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${draftAccess} = ${stateName}`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${draftAccess} = ${stateName} || 0`
|
||||
}
|
||||
return ` ${draftAccess} = ${stateName}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate reset states
|
||||
const resetStates = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const setterName = `set${toPascalCase(safeName)}`
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${setterName}(false)`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${setterName}(0)`
|
||||
}
|
||||
return ` ${setterName}('')`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate validation (check required fields)
|
||||
const requiredEditableFields = editableFields.filter(([name]) => required.includes(name))
|
||||
const validationCheck =
|
||||
requiredEditableFields.length > 0
|
||||
? requiredEditableFields
|
||||
.map(([name, prop]) => {
|
||||
const safeName = sanitizeIdentifier(name)
|
||||
const stateName = toCamelCase(safeName)
|
||||
if (prop.type === 'boolean') return null
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return `${stateName} === ''`
|
||||
}
|
||||
return `!${stateName}.trim()`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' || ')
|
||||
: null
|
||||
|
||||
return `'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import { ${collectionName} } from '@/lib/db'
|
||||
import { ${pascalName} } from '@/lib/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
|
||||
interface ${sheetComponentName}Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mode: 'create' | 'edit'
|
||||
item?: ${pascalName} | null
|
||||
}
|
||||
|
||||
export function ${sheetComponentName}({ open, onOpenChange, mode, item }: ${sheetComponentName}Props) {
|
||||
${stateDeclarations}
|
||||
|
||||
const isCreate = mode === 'create'
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
${useEffectReset}
|
||||
}
|
||||
}, [open, item])
|
||||
|
||||
const handleSubmit = () => {
|
||||
${validationCheck ? ` if (${validationCheck}) return\n` : ''}
|
||||
if (isCreate) {
|
||||
${collectionName}.insert({
|
||||
${insertData}
|
||||
})
|
||||
} else if (item) {
|
||||
${collectionName}.update(${propAccess('item', primaryKey)}, (draft) => {
|
||||
${updateData}
|
||||
})
|
||||
}
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
${resetStates}
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isCreate ? 'Create New ${singularPascal}' : 'Edit ${singularPascal}'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isCreate
|
||||
? 'Add a new ${singularName.replace(/_/g, ' ')} to your database.'
|
||||
: 'Update the ${singularName.replace(/_/g, ' ')} information.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-6">
|
||||
{/* Read-only fields (only in edit mode) */}
|
||||
{!isCreate && item && (
|
||||
<>
|
||||
${readOnlyFormFields}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Editable fields */}
|
||||
${editableFormFields}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
{isCreate ? 'Create ${singularPascal}' : 'Save Changes'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { RegistryItem } from 'shadcn/schema'
|
||||
|
||||
import {
|
||||
generateDbContent,
|
||||
generateListContent,
|
||||
generatePageContent,
|
||||
generateSchemasContent,
|
||||
generateSheetContent,
|
||||
} from './_generators'
|
||||
import { OpenAPISchema, RegistryFile } from './types'
|
||||
import { safeFileSegment, toSingular, validateDefinitionNames } from './utils'
|
||||
|
||||
// Read the base registry JSON
|
||||
async function readBaseRegistry(): Promise<RegistryItem> {
|
||||
const fullPath = path.join(process.cwd(), 'public/r/tanstack-db-nextjs.json')
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
return JSON.parse(content) as RegistryItem
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const ref = searchParams.get('ref')
|
||||
const anonKey = searchParams.get('anonKey')
|
||||
|
||||
if (!ref || !anonKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: ref and anonKey' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate Supabase project ref to prevent SSRF via crafted hostnames
|
||||
// Typical Supabase refs are lowercase alphabetic; adjust if needed.
|
||||
const refPattern = /^[a-z]{1,64}$/
|
||||
if (!refPattern.test(ref)) {
|
||||
return NextResponse.json({ error: 'Invalid project ref format.' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch OpenAPI spec from Supabase
|
||||
const openApiUrl = `https://${ref}.supabase.co/rest/v1/`
|
||||
const response = await fetch(openApiUrl, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch OpenAPI spec: ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const openApiSpec: OpenAPISchema = await response.json()
|
||||
|
||||
if (!openApiSpec.definitions) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No table definitions found in OpenAPI spec' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate all table/column names before generating any code
|
||||
try {
|
||||
validateDefinitionNames(openApiSpec.definitions)
|
||||
} catch (validationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid table or column name: ${validationError instanceof Error ? validationError.message : String(validationError)}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate dynamic file contents
|
||||
const schemasContent = generateSchemasContent(openApiSpec.definitions)
|
||||
const dbContent = generateDbContent(openApiSpec.definitions)
|
||||
|
||||
// Get the first table for the example CRUD page
|
||||
const tableNames = Object.keys(openApiSpec.definitions).filter((name) => !name.startsWith('_'))
|
||||
// TODO: remove this once we have a way to select the table
|
||||
const firstTableName = tableNames.length > 0 ? tableNames[0] : null
|
||||
const firstTableDefinition = firstTableName ? openApiSpec.definitions[firstTableName] : null
|
||||
|
||||
// Read the base registry JSON
|
||||
const baseRegistry = await readBaseRegistry()
|
||||
|
||||
// Create dynamic files
|
||||
const dynamicFiles: RegistryFile[] = [
|
||||
{
|
||||
path: 'lib/schemas.ts',
|
||||
content: schemasContent,
|
||||
type: 'registry:lib',
|
||||
},
|
||||
{
|
||||
path: 'lib/db.ts',
|
||||
content: dbContent,
|
||||
type: 'registry:lib',
|
||||
},
|
||||
]
|
||||
|
||||
// Add example CRUD pages for the first table
|
||||
if (firstTableName && firstTableDefinition) {
|
||||
const singularTableName = toSingular(firstTableName)
|
||||
const safeTable = safeFileSegment(firstTableName)
|
||||
const safeSingular = safeFileSegment(singularTableName)
|
||||
|
||||
dynamicFiles.push(
|
||||
{
|
||||
path: `app/${safeTable}/page.tsx`,
|
||||
content: generatePageContent(firstTableName),
|
||||
type: 'registry:page',
|
||||
target: `app/${safeTable}/page.tsx`,
|
||||
},
|
||||
{
|
||||
path: `app/${safeTable}/${safeSingular}-sheet.tsx`,
|
||||
content: generateSheetContent(firstTableName, firstTableDefinition),
|
||||
type: 'registry:component',
|
||||
target: `app/${safeTable}/${safeSingular}-sheet.tsx`,
|
||||
},
|
||||
{
|
||||
path: `app/${safeTable}/${safeTable}-list.tsx`,
|
||||
content: generateListContent(firstTableName, firstTableDefinition),
|
||||
type: 'registry:component',
|
||||
target: `app/${safeTable}/${safeTable}-list.tsx`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Combine dynamic files with base registry files
|
||||
const registryResponse: RegistryItem = {
|
||||
...baseRegistry,
|
||||
files: [...dynamicFiles, ...(baseRegistry.files || [])],
|
||||
envVars: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: `https://${ref}.supabase.co`,
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: anonKey,
|
||||
},
|
||||
docs: '',
|
||||
}
|
||||
|
||||
return NextResponse.json(registryResponse)
|
||||
} catch (error) {
|
||||
console.error('Error generating tanstack-db block:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate block. Please check your project ref and anon key.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RegistryItem } from 'shadcn/schema'
|
||||
|
||||
export interface OpenAPISchema {
|
||||
definitions?: Record<string, OpenAPIDefinition>
|
||||
}
|
||||
|
||||
export interface OpenAPIDefinition {
|
||||
type: string
|
||||
properties?: Record<string, OpenAPIProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
export interface OpenAPIProperty {
|
||||
type?: string
|
||||
format?: string
|
||||
description?: string
|
||||
default?: unknown
|
||||
maxLength?: number
|
||||
enum?: string[]
|
||||
items?: { type?: string; format?: string }
|
||||
}
|
||||
|
||||
export type RegistryFile = NonNullable<RegistryItem['files']>[number]
|
||||
@@ -0,0 +1,194 @@
|
||||
import { OpenAPIProperty } from './types'
|
||||
|
||||
// Valid JS identifier pattern: starts with letter or underscore, contains only alphanumeric and underscores
|
||||
const VALID_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Validate that a name is safe for use as a JS/TS identifier.
|
||||
* Throws if the name contains characters that cannot produce a valid identifier.
|
||||
*/
|
||||
export function sanitizeIdentifier(name: string): string {
|
||||
// Replace common non-identifier characters with underscores
|
||||
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||
// Ensure it doesn't start with a digit
|
||||
if (/^[0-9]/.test(sanitized)) {
|
||||
sanitized = `_${sanitized}`
|
||||
}
|
||||
// Collapse multiple underscores
|
||||
sanitized = sanitized.replace(/_+/g, '_').replace(/^_+|_+$/g, '') || '_'
|
||||
|
||||
if (!VALID_IDENTIFIER_RE.test(sanitized)) {
|
||||
throw new Error(
|
||||
`Cannot safely map name "${name}" to a valid identifier. Names must contain only letters, digits, and underscores.`
|
||||
)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a name is safe for use as a file path segment.
|
||||
* Rejects traversal sequences, slashes, and other dangerous characters.
|
||||
*/
|
||||
export function safeFileSegment(name: string): string {
|
||||
if (
|
||||
name.includes('/') ||
|
||||
name.includes('\\') ||
|
||||
name.includes('..') ||
|
||||
name.includes('\0') ||
|
||||
name.startsWith('.')
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsafe file segment: "${name}". Names must not contain path separators, traversal sequences, or start with a dot.`
|
||||
)
|
||||
}
|
||||
|
||||
// Only allow alphanumeric, underscores, and hyphens in file segments
|
||||
const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
if (!sanitized) {
|
||||
throw new Error(`Cannot safely map name "${name}" to a file path segment.`)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit safe property access for generated code.
|
||||
* Uses dot notation for identifier-safe names, bracket notation otherwise.
|
||||
*/
|
||||
export function propAccess(obj: string, name: string): string {
|
||||
if (VALID_IDENTIFIER_RE.test(name)) {
|
||||
return `${obj}.${name}`
|
||||
}
|
||||
return `${obj}[${JSON.stringify(name)}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all table and column names from OpenAPI definitions upfront.
|
||||
* Throws a descriptive error if any name cannot be safely used.
|
||||
*/
|
||||
export function validateDefinitionNames(
|
||||
definitions: Record<string, { properties?: Record<string, unknown> }>
|
||||
): void {
|
||||
for (const [tableName, def] of Object.entries(definitions)) {
|
||||
if (tableName.startsWith('_')) continue
|
||||
// Validate table name can be used as identifier and file segment
|
||||
sanitizeIdentifier(tableName)
|
||||
safeFileSegment(tableName)
|
||||
|
||||
if (def.properties) {
|
||||
for (const colName of Object.keys(def.properties)) {
|
||||
// Validate column names can at minimum be used as quoted properties
|
||||
if (colName.includes('\0') || colName.includes('\\')) {
|
||||
throw new Error(
|
||||
`Unsafe column name "${colName}" in table "${tableName}". Column names must not contain null bytes or backslashes.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert OpenAPI type to Zod type
|
||||
export function openApiTypeToZod(prop: OpenAPIProperty, isRequired: boolean): string {
|
||||
let zodType: string
|
||||
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
if (prop.format === 'uuid') {
|
||||
zodType = 'z.string().uuid()'
|
||||
} else if (prop.format === 'date-time' || prop.format === 'timestamp with time zone') {
|
||||
zodType = 'z.string()'
|
||||
} else if (prop.enum && prop.enum.length > 0) {
|
||||
const enumValues = prop.enum.map((e) => `'${e.replace(/'/g, "\\'")}'`).join(', ')
|
||||
zodType = `z.enum([${enumValues}])`
|
||||
} else {
|
||||
zodType = 'z.string()'
|
||||
}
|
||||
break
|
||||
case 'integer':
|
||||
zodType = 'z.number().int()'
|
||||
break
|
||||
case 'number':
|
||||
zodType = 'z.number()'
|
||||
break
|
||||
case 'boolean':
|
||||
zodType = 'z.boolean()'
|
||||
break
|
||||
case 'array':
|
||||
if (prop.items) {
|
||||
const itemType = openApiTypeToZod(prop.items as OpenAPIProperty, true)
|
||||
zodType = `z.array(${itemType})`
|
||||
} else {
|
||||
zodType = 'z.array(z.unknown())'
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
zodType = 'z.record(z.unknown())'
|
||||
break
|
||||
default:
|
||||
zodType = 'z.unknown()'
|
||||
}
|
||||
|
||||
// Add nullable if not required
|
||||
if (!isRequired) {
|
||||
zodType += '.nullable()'
|
||||
}
|
||||
|
||||
return zodType
|
||||
}
|
||||
|
||||
// Convert table name to PascalCase for type names
|
||||
export function toPascalCase(str: string): string {
|
||||
return str
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Convert table name to camelCase for variable names
|
||||
export function toCamelCase(str: string): string {
|
||||
const pascal = toPascalCase(str)
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1)
|
||||
}
|
||||
|
||||
// Convert field name to human-readable label
|
||||
export function toLabel(fieldName: string): string {
|
||||
return fieldName
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Get singular form of table name (simple heuristic)
|
||||
export function toSingular(tableName: string): string {
|
||||
if (tableName.endsWith('ies')) {
|
||||
return tableName.slice(0, -3) + 'y'
|
||||
}
|
||||
if (tableName.endsWith('ses') || tableName.endsWith('xes') || tableName.endsWith('zes')) {
|
||||
return tableName.slice(0, -2)
|
||||
}
|
||||
if (tableName.endsWith('s') && !tableName.endsWith('ss')) {
|
||||
return tableName.slice(0, -1)
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
|
||||
// Find primary key columns from properties
|
||||
export function findPrimaryKeys(properties: Record<string, OpenAPIProperty>): string[] {
|
||||
const primaryKeys: string[] = []
|
||||
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
// Check if the description mentions primary key
|
||||
if (prop.description?.toLowerCase().includes('primary key')) {
|
||||
primaryKeys.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
// If no primary keys found, default to 'id' if it exists
|
||||
if (primaryKeys.length === 0 && properties['id']) {
|
||||
primaryKeys.push('id')
|
||||
}
|
||||
|
||||
return primaryKeys
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { DualRealtimeChat } from './dual-realtime-chat'
|
||||
import { RegistryBlock } from './registry-block'
|
||||
import { StyleWrapper } from './style-wrapper'
|
||||
import TanStackBeta from './tanstack-beta'
|
||||
import { TanstackDBGenerator } from './tanstack-db-generator'
|
||||
import type { Style } from '@/registry/styles'
|
||||
|
||||
const components = {
|
||||
@@ -181,6 +182,7 @@ const components = {
|
||||
BlockItem,
|
||||
BlockPreview,
|
||||
DualRealtimeChat,
|
||||
TanstackDBGenerator,
|
||||
}
|
||||
|
||||
interface MdxProps {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import noop from 'lodash/noop'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button_Shadcn_ as Button,
|
||||
cn,
|
||||
Command_Shadcn_ as Command,
|
||||
CommandGroup_Shadcn_ as CommandGroup,
|
||||
CommandInput_Shadcn_ as CommandInput,
|
||||
CommandItem_Shadcn_ as CommandItem,
|
||||
CommandList_Shadcn_ as CommandList,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
PopoverTrigger_Shadcn_ as PopoverTrigger,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'
|
||||
|
||||
export interface ComboBoxOption {
|
||||
id: string
|
||||
value: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function ComboBox<Opt extends ComboBoxOption>({
|
||||
isLoading,
|
||||
disabled,
|
||||
name,
|
||||
options,
|
||||
selectedOption,
|
||||
selectedDisplayName,
|
||||
onSelectOption = noop,
|
||||
className,
|
||||
search = '',
|
||||
hasNextPage = false,
|
||||
isFetching = false,
|
||||
isFetchingNextPage = false,
|
||||
fetchNextPage,
|
||||
setSearch = () => {},
|
||||
useCommandSearch = true,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
disabled?: boolean
|
||||
name: string
|
||||
options: Opt[]
|
||||
selectedOption?: string
|
||||
selectedDisplayName?: string
|
||||
onSelectOption?: (newValue: string) => void
|
||||
className?: string
|
||||
search?: string
|
||||
hasNextPage?: boolean
|
||||
isFetching?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
fetchNextPage?: () => void
|
||||
setSearch?: (value: string) => void
|
||||
useCommandSearch?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sentinelRef, entry] = useIntersectionObserver({
|
||||
root: scrollRootRef.current,
|
||||
threshold: 0,
|
||||
rootMargin: '0px',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isFetchingNextPage && hasNextPage && entry?.isIntersecting) {
|
||||
fetchNextPage?.()
|
||||
}
|
||||
}, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
setOpen(value)
|
||||
if (!value) setSearch('')
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'overflow-hidden',
|
||||
'h-auto min-h-10',
|
||||
'flex justify-between',
|
||||
'border-none',
|
||||
'py-0 pl-0 pr-1 text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{selectedDisplayName ??
|
||||
(isLoading && options.length > 0
|
||||
? 'Loading...'
|
||||
: options.length === 0
|
||||
? `No ${name} found`
|
||||
: `Select a ${name}...`)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
<Command shouldFilter={useCommandSearch}>
|
||||
<CommandInput
|
||||
placeholder={`Search ${name}...`}
|
||||
className="border-none ring-0"
|
||||
showResetIcon
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
handleReset={() => setSearch('')}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-1 flex flex-col gap-2">
|
||||
<ShimmeringLoader className="w-full" />
|
||||
<ShimmeringLoader className="w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{search.length > 0 && options.length === 0 && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No {name}s found based on your search
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea className={options.length > 7 ? 'h-[210px]' : ''}>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
onSelect={(selectedValue: string) => {
|
||||
setOpen(false)
|
||||
onSelectOption(selectedValue)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedOption === option.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.displayName}
|
||||
</CommandItem>
|
||||
))}
|
||||
<div ref={sentinelRef} className="h-1 -mt-1" />
|
||||
{hasNextPage && <ShimmeringLoader className="px-2 py-3" />}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ComboBox, type ComboBoxOption } from './ComboBox'
|
||||
import { fromOrgProjectValue, toDisplayNameOrgProject, toOrgProjectValue, type Org } from './utils'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { organizationsQueryOptions } from '@/lib/fetch/organizations'
|
||||
import { projectKeysQueryOptions } from '@/lib/fetch/projectApi'
|
||||
import { projectsInfiniteQueryOptions, type ProjectInfoInfinite } from '@/lib/fetch/projects'
|
||||
import { retrieve, storeOrRemoveNull } from '@/lib/storage'
|
||||
|
||||
interface ProjectPickerProps {
|
||||
onProjectResolved: (data: { projectRef: string; anonKey: string } | null) => void
|
||||
}
|
||||
|
||||
export function ProjectPicker({ onProjectResolved }: ProjectPickerProps) {
|
||||
const isUserLoading = useIsUserLoading()
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
|
||||
const [selectedOrg, setSelectedOrg] = useState<Org | null>(null)
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectInfoInfinite | null>(null)
|
||||
const [missingAnonKey, setMissingAnonKey] = useState(false)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const {
|
||||
data: organizations,
|
||||
isPending: organizationsIsPending,
|
||||
isError: organizationsIsError,
|
||||
} = useQuery(organizationsQueryOptions({ enabled: isLoggedIn }))
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
isPending: projectsIsPending,
|
||||
isError: projectsIsError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery(
|
||||
projectsInfiniteQueryOptions(
|
||||
{ search: search.length === 0 ? search : debouncedSearch },
|
||||
{ enabled: isLoggedIn }
|
||||
)
|
||||
)
|
||||
const projects = useMemo(
|
||||
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
|
||||
[projectsData?.pages]
|
||||
)
|
||||
|
||||
const anyIsPending = organizationsIsPending || projectsIsPending
|
||||
const anyIsError = organizationsIsError || projectsIsError
|
||||
|
||||
const isReady =
|
||||
isLoggedIn && !isUserLoading && !anyIsPending && !anyIsError && projects?.length > 0
|
||||
|
||||
const formattedData: ComboBoxOption[] = useMemo(
|
||||
() =>
|
||||
!isReady
|
||||
? []
|
||||
: (projects!
|
||||
.map((project) => {
|
||||
const organization = organizations!.find((org) => org.id === project.organization_id)!
|
||||
return {
|
||||
id: project.ref,
|
||||
value: toOrgProjectValue(organization, project),
|
||||
displayName: toDisplayNameOrgProject(organization, project),
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ComboBoxOption[]),
|
||||
[organizations, projects, isReady]
|
||||
)
|
||||
|
||||
// Auto-select from localStorage or first project
|
||||
useEffect(() => {
|
||||
if (isReady && (!selectedOrg || !selectedProject)) {
|
||||
const storedMaybeOrgId = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_ORG)
|
||||
const storedMaybeProjectRef = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT)
|
||||
|
||||
let storedOrg: Org | undefined
|
||||
let storedProject: ProjectInfoInfinite | undefined
|
||||
if (storedMaybeOrgId && storedMaybeProjectRef) {
|
||||
storedOrg = organizations!.find((org) => org.id === Number(storedMaybeOrgId))
|
||||
storedProject = projects!.find((project) => project.ref === storedMaybeProjectRef)
|
||||
}
|
||||
|
||||
if (storedOrg && storedProject && storedProject.organization_id === storedOrg.id) {
|
||||
setSelectedOrg(storedOrg)
|
||||
setSelectedProject(storedProject)
|
||||
} else if (projects!.length > 0) {
|
||||
const firstProject = projects![0]
|
||||
const matchingOrg = organizations!.find((org) => org.id === firstProject.organization_id)
|
||||
if (matchingOrg) {
|
||||
setSelectedOrg(matchingOrg)
|
||||
setSelectedProject(firstProject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [organizations, projects, selectedOrg, selectedProject, isReady])
|
||||
|
||||
function handleSelectOrgProject(optionValue: string) {
|
||||
const [orgId, projectRef] = fromOrgProjectValue(optionValue)
|
||||
if (!orgId || !projectRef) return
|
||||
|
||||
const org = organizations?.find((org) => org.id === orgId)
|
||||
const project = projects?.find((project) => project.ref === projectRef)
|
||||
|
||||
if (org && project && project.organization_id === org.id) {
|
||||
setSelectedOrg(org)
|
||||
setSelectedProject(project)
|
||||
storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_ORG, org.id.toString())
|
||||
storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT, project.ref)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch API keys for the selected project
|
||||
const {
|
||||
data: apiKeysData,
|
||||
isPending: isApiKeysPending,
|
||||
isError: isApiKeysError,
|
||||
} = useQuery(
|
||||
projectKeysQueryOptions(
|
||||
{ projectRef: selectedProject?.ref },
|
||||
{ enabled: isLoggedIn && !!selectedProject?.ref }
|
||||
)
|
||||
)
|
||||
|
||||
// Resolve the key and notify parent
|
||||
useEffect(() => {
|
||||
if (!selectedProject?.ref || isApiKeysPending || isApiKeysError || !apiKeysData) {
|
||||
onProjectResolved(null)
|
||||
return
|
||||
}
|
||||
|
||||
const anonKey = apiKeysData.find((k) => k.type === 'legacy' && k.name === 'anon')?.api_key
|
||||
|
||||
if (anonKey) {
|
||||
setMissingAnonKey(false)
|
||||
onProjectResolved({ projectRef: selectedProject.ref, anonKey })
|
||||
} else {
|
||||
setMissingAnonKey(true)
|
||||
onProjectResolved(null)
|
||||
}
|
||||
}, [selectedProject?.ref, apiKeysData, isApiKeysPending, isApiKeysError, onProjectResolved])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ComboBox
|
||||
name="project"
|
||||
isLoading={isUserLoading || anyIsPending}
|
||||
disabled={!isLoggedIn || anyIsError || (projects?.length === 0 && !anyIsPending)}
|
||||
options={formattedData}
|
||||
selectedDisplayName={
|
||||
selectedOrg && selectedProject
|
||||
? toDisplayNameOrgProject(selectedOrg, selectedProject)
|
||||
: undefined
|
||||
}
|
||||
selectedOption={
|
||||
selectedOrg && selectedProject
|
||||
? toOrgProjectValue(selectedOrg, selectedProject)
|
||||
: undefined
|
||||
}
|
||||
onSelectOption={handleSelectOrgProject}
|
||||
search={search}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
setSearch={setSearch}
|
||||
useCommandSearch={false}
|
||||
/>
|
||||
{isApiKeysPending && selectedProject && (
|
||||
<p className="text-xs text-muted-foreground">Loading API keys...</p>
|
||||
)}
|
||||
{isApiKeysError && selectedProject && (
|
||||
<p className="text-xs text-destructive">Failed to load API keys for this project.</p>
|
||||
)}
|
||||
{missingAnonKey && selectedProject && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
This project does not have an anon key. Please enable the anon key in your project's
|
||||
API settings to use this block.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useIsLoggedIn, useIsUserLoading } from 'common'
|
||||
import { Check, Copy, Loader2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
copyToClipboard,
|
||||
Tabs_Shadcn_ as Tabs,
|
||||
TabsContent_Shadcn_ as TabsContent,
|
||||
TabsList_Shadcn_ as TabsList,
|
||||
TabsTrigger_Shadcn_ as TabsTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { useLocalStorage } from '../use-local-storage'
|
||||
import { ProjectPicker } from './ProjectPicker'
|
||||
|
||||
type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'package-manager-tanstack-db'
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
|
||||
return 'https://supabase.com/ui'
|
||||
} else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
|
||||
} else {
|
||||
return 'http://localhost:3004/ui'
|
||||
}
|
||||
}
|
||||
|
||||
export function TanstackDBGenerator() {
|
||||
const isUserLoading = useIsUserLoading()
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
|
||||
const [resolvedProject, setResolvedProject] = useState<{
|
||||
projectRef: string
|
||||
anonKey: string
|
||||
} | null>(null)
|
||||
const [hasCopied, setHasCopied] = useState(false)
|
||||
const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
|
||||
|
||||
const handleProjectResolved = useCallback(
|
||||
(data: { projectRef: string; anonKey: string } | null) => {
|
||||
setResolvedProject(data)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiUrl = resolvedProject
|
||||
? `${baseUrl}${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/registry/tanstack-db?ref=${encodeURIComponent(resolvedProject.projectRef)}&anonKey=${encodeURIComponent(resolvedProject.anonKey)}`
|
||||
: ''
|
||||
|
||||
const commands: Record<PackageManager, string> = {
|
||||
npm: `npx shadcn@latest add "${apiUrl}"`,
|
||||
pnpm: `pnpm dlx shadcn@latest add "${apiUrl}"`,
|
||||
yarn: `yarn dlx shadcn@latest add "${apiUrl}"`,
|
||||
bun: `bunx --bun shadcn@latest add "${apiUrl}"`,
|
||||
}
|
||||
|
||||
const handleCopy = (command: string) => {
|
||||
copyToClipboard(command)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (isUserLoading) {
|
||||
return (
|
||||
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="mt-6 rounded-md border border-default bg-surface-100 p-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
<a
|
||||
href="https://supabase.com/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-foreground underline"
|
||||
>
|
||||
Log in
|
||||
</a>{' '}
|
||||
to your Supabase account to generate installation commands for your project.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Select a project</label>
|
||||
<ProjectPicker onProjectResolved={handleProjectResolved} />
|
||||
</div>
|
||||
|
||||
{resolvedProject && (
|
||||
<>
|
||||
<div className="rounded-md bg-brand-400/10 border border-brand-400/20 p-3 text-sm text-brand-600">
|
||||
Copy the command below to install the block.
|
||||
</div>
|
||||
|
||||
<Tabs value={packageManager} onValueChange={setPackageManager} className="w-full">
|
||||
<div className="w-full group relative rounded-lg bg-surface-200 dark:bg-surface-100 px-4 py-2 overflow-hidden">
|
||||
<div className="flex flex-col">
|
||||
<TabsList className="gap-2 relative mb-2 z-10">
|
||||
{(Object.keys(commands) as PackageManager[]).map((manager) => (
|
||||
<TabsTrigger key={manager} value={manager} className="text-xs">
|
||||
{manager}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{(Object.keys(commands) as PackageManager[]).map((manager) => (
|
||||
<TabsContent key={manager} value={manager} className="m-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 font-mono text-sm text-foreground relative z-10 overflow-x-auto">
|
||||
<span className="mr-2 text-[#888] select-none">$</span>
|
||||
<span className="whitespace-nowrap">{commands[manager]}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
type="outline"
|
||||
className={cn(
|
||||
'relative z-10 h-6 w-6 text-foreground-muted hover:bg-surface-100 hover:text-foreground p-0 shrink-0'
|
||||
)}
|
||||
onClick={() => handleCopy(commands[manager])}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? (
|
||||
<Check className="h-3 w-3 text-brand-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { OrganizationsData } from '@/lib/fetch/organizations'
|
||||
import type { ProjectInfoInfinite } from '@/lib/fetch/projects'
|
||||
|
||||
export type Org = OrganizationsData[number]
|
||||
|
||||
function removeDoubleQuotes(str: string) {
|
||||
return str.replaceAll('"', '')
|
||||
}
|
||||
|
||||
function escapeDoubleQuotes(str: string) {
|
||||
return str.replaceAll('"', '\\"')
|
||||
}
|
||||
|
||||
function unescapeDoubleQuotes(str: string) {
|
||||
/**
|
||||
* Regex matches a backslash followed by a double quote, except when the
|
||||
* backslash is escaped.
|
||||
*/
|
||||
return str.replace(/(?<!(?:^|[^\\])\\(?:\\\\)*)\\"/g, '"')
|
||||
}
|
||||
|
||||
type DeepReadonly<T> = {
|
||||
readonly [P in keyof T]: DeepReadonly<T[P]>
|
||||
}
|
||||
|
||||
export function toDisplayNameOrgProject(
|
||||
org: DeepReadonly<Org>,
|
||||
project: DeepReadonly<ProjectInfoInfinite>
|
||||
) {
|
||||
return `${org.name} / ${project.name}`
|
||||
}
|
||||
|
||||
export function toOrgProjectValue(
|
||||
org: DeepReadonly<Org>,
|
||||
project: DeepReadonly<ProjectInfoInfinite>
|
||||
) {
|
||||
return escapeDoubleQuotes(
|
||||
// @ts-ignore -- problem in OpenAPI spec -- project has ref property
|
||||
JSON.stringify([org.id, project.ref, removeDoubleQuotes(toDisplayNameOrgProject(org, project))])
|
||||
)
|
||||
}
|
||||
|
||||
export function fromOrgProjectValue(
|
||||
maybeOrgProject: string
|
||||
): [number, string, string] | [null, null, null] {
|
||||
try {
|
||||
const data = JSON.parse(unescapeDoubleQuotes(maybeOrgProject))
|
||||
if (!Array.isArray(data) || data.length !== 3) {
|
||||
throw Error("Shape of parsed JSON doesn't match form of org and project value")
|
||||
}
|
||||
return data as [number, string, string]
|
||||
} catch {
|
||||
return [null, null, null]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: TanStack DB
|
||||
description: Local-first, reactive collections backed by Supabase
|
||||
---
|
||||
|
||||
A [TanStack/db](https://tanstack.com/db/latest) collection backed by [Supabase](https://supabase.com/). It wires up Supabase PostgREST queries, mutations, and real-time subscriptions so you get a reactive state that stays in sync with your Postgres database.
|
||||
|
||||
## Installation
|
||||
|
||||
Enter your Supabase project credentials to generate a personalized installation command. The block will include schemas and collections for all tables in your database.
|
||||
|
||||
<TanstackDBGenerator />
|
||||
|
||||
<Callout type="info">
|
||||
Your credentials are only used to fetch your database schema. They are not stored or sent to any
|
||||
server other than your Supabase project.
|
||||
</Callout>
|
||||
|
||||
## What's included
|
||||
|
||||
This block installs the following files:
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------- | -------------------------------------------------- |
|
||||
| `lib/schemas.ts` | Zod schemas for all your database tables |
|
||||
| `lib/db.ts` | Supabase collections for type-safe CRUD operations |
|
||||
| `lib/supabase/client.ts` | Browser Supabase client |
|
||||
| `lib/supabase/server.ts` | Server-side Supabase client |
|
||||
| `lib/supabase/middleware.ts` | Middleware for session handling |
|
||||
| `app/providers.tsx` | Provider component for TanStack DB |
|
||||
|
||||
## Getting started
|
||||
|
||||
After installing the block, add the required environment variables to your `.env.local` file:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Further reading
|
||||
|
||||
- [TanStack DB documentation](https://tanstack.com/db/latest)
|
||||
- [Supabase JavaScript client](https://supabase.com/docs/reference/javascript)
|
||||
- [Generating TypeScript types](https://supabase.com/docs/guides/api/rest/generating-types)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L239
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { RefCallback, useCallback, useRef, useState } from 'react'
|
||||
|
||||
// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L512
|
||||
export function useIntersectionObserver<T extends Element>(
|
||||
options: {
|
||||
root?: Element | Document | null
|
||||
rootMargin?: string
|
||||
threshold?: number | number[]
|
||||
} = {}
|
||||
): [RefCallback<T>, IntersectionObserverEntry | null] {
|
||||
const { threshold = 1, root = null, rootMargin = '0px' } = options
|
||||
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null)
|
||||
|
||||
const previousObserver = useRef<IntersectionObserver | null>(null)
|
||||
|
||||
const customRef = useCallback(
|
||||
(node: T | null) => {
|
||||
if (previousObserver.current) {
|
||||
previousObserver.current.disconnect()
|
||||
previousObserver.current = null
|
||||
}
|
||||
|
||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setEntry(entry)
|
||||
},
|
||||
{ threshold, root, rootMargin }
|
||||
)
|
||||
|
||||
observer.observe(node)
|
||||
previousObserver.current = observer
|
||||
}
|
||||
},
|
||||
[threshold, root, rootMargin]
|
||||
)
|
||||
|
||||
return [customRef, entry]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { paths } from 'api-types'
|
||||
import createClient from 'openapi-fetch'
|
||||
|
||||
import { API_URL } from '@/lib/constants'
|
||||
import { getAccessToken } from '@/lib/userAuth'
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
const client = createClient<paths>({
|
||||
baseUrl: API_URL.replace('/platform', ''),
|
||||
referrerPolicy: 'no-referrer-when-downgrade',
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
|
||||
async function constructHeaders(headersInit?: HeadersInit | undefined) {
|
||||
const requestId = crypto.randomUUID()
|
||||
const headers = new Headers(headersInit)
|
||||
|
||||
headers.set('X-Request-Id', requestId)
|
||||
|
||||
if (!headers.has('Authorization')) {
|
||||
const accessToken = await getAccessToken()
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
client.use({
|
||||
async onRequest({ request }) {
|
||||
const headers = await constructHeaders(request.headers)
|
||||
headers.forEach((value, key) => {
|
||||
request.headers.set(key, value)
|
||||
})
|
||||
|
||||
return request
|
||||
},
|
||||
})
|
||||
|
||||
export const { GET: get, POST: post } = client
|
||||
@@ -0,0 +1,27 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { get } from './fetchWrappers'
|
||||
|
||||
const organizationKeys = {
|
||||
list: () => ['organizations'] as const,
|
||||
}
|
||||
|
||||
async function getOrganizations(signal?: AbortSignal) {
|
||||
// The generated api response in api.d.ts is typed as OrganizationResponseV1
|
||||
// but the actual response should be typed as OrganizationResponse.
|
||||
const { data, error } = await get('/platform/organizations', { signal })
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export type OrganizationsData = Awaited<ReturnType<typeof getOrganizations>>
|
||||
|
||||
export const organizationsQueryOptions = (
|
||||
{ enabled = true }: { enabled?: boolean } = { enabled: true }
|
||||
) => {
|
||||
return queryOptions({
|
||||
queryKey: organizationKeys.list(),
|
||||
queryFn: ({ signal }) => getOrganizations(signal),
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { get } from './fetchWrappers'
|
||||
|
||||
export interface ProjectApiVariables {
|
||||
projectRef?: string
|
||||
}
|
||||
|
||||
export type ProjectKeys = Awaited<ReturnType<typeof getProjectKeys>>
|
||||
|
||||
export type ProjectSettings = Awaited<ReturnType<typeof getProjectSettings>>
|
||||
|
||||
const projectApiKeys = {
|
||||
api: (projectRef: string | undefined) => ['projects', projectRef, 'api'] as const,
|
||||
settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const,
|
||||
}
|
||||
|
||||
async function getProjectKeys({ projectRef }: ProjectApiVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) {
|
||||
throw Error('projectRef is required')
|
||||
}
|
||||
|
||||
const { data, error } = await get('/v1/projects/{ref}/api-keys', {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
async function getProjectSettings({ projectRef }: ProjectApiVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) {
|
||||
throw Error('projectRef is required')
|
||||
}
|
||||
|
||||
const { data, error } = await get('/platform/projects/{ref}/settings', {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export const projectKeysQueryOptions = (
|
||||
{ projectRef }: ProjectApiVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = { enabled: true }
|
||||
) => {
|
||||
return queryOptions({
|
||||
queryKey: projectApiKeys.api(projectRef),
|
||||
queryFn: ({ signal }) => getProjectKeys({ projectRef }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
})
|
||||
}
|
||||
|
||||
export const projectSettingsQueryOptions = (
|
||||
{ projectRef }: ProjectApiVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = { enabled: true }
|
||||
) => {
|
||||
return queryOptions({
|
||||
queryKey: projectApiKeys.settings(projectRef),
|
||||
queryFn: ({ signal }) => getProjectSettings({ projectRef }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { infiniteQueryOptions } from '@tanstack/react-query'
|
||||
import { components } from 'api-types'
|
||||
import { get } from './fetchWrappers'
|
||||
|
||||
const DEFAULT_LIMIT = 10
|
||||
const projectKeys = {
|
||||
listInfinite: (params?: {
|
||||
limit: number
|
||||
sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
|
||||
search?: string
|
||||
}) => ['all-projects-infinite', params].filter(Boolean),
|
||||
}
|
||||
|
||||
interface GetProjectsInfiniteVariables {
|
||||
limit?: number
|
||||
sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
|
||||
search?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
export type ProjectInfoInfinite =
|
||||
components['schemas']['ListProjectsPaginatedResponse']['projects'][number]
|
||||
|
||||
async function getProjects(
|
||||
{
|
||||
limit = DEFAULT_LIMIT,
|
||||
page = 0,
|
||||
sort = 'name_asc',
|
||||
search: _search = '',
|
||||
}: GetProjectsInfiniteVariables,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
const offset = page * limit
|
||||
const search = _search.length === 0 ? undefined : _search
|
||||
|
||||
const { data, error } = await get('/platform/projects', {
|
||||
// @ts-ignore [Joshen] API type issue for Version 2 endpoints
|
||||
params: { query: { limit, offset, sort, search } },
|
||||
signal,
|
||||
headers: { ...headers, Version: '2' },
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data as unknown as components['schemas']['ListProjectsPaginatedResponse']
|
||||
}
|
||||
|
||||
export type ProjectsInfiniteData = Awaited<ReturnType<typeof getProjects>>
|
||||
|
||||
export const projectsInfiniteQueryOptions = (
|
||||
{ limit = DEFAULT_LIMIT, sort = 'name_asc', search }: GetProjectsInfiniteVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = { enabled: true }
|
||||
) => {
|
||||
return infiniteQueryOptions({
|
||||
enabled,
|
||||
queryKey: projectKeys.listInfinite({ limit, sort, search }),
|
||||
queryFn: ({ signal, pageParam }) =>
|
||||
getProjects({ limit, page: pageParam as number, sort, search }, signal),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
const page = pages.length
|
||||
const currentTotalCount = page * limit
|
||||
// @ts-ignore [Joshen] API type issue for Version 2 endpoints
|
||||
const totalCount = lastPage.pagination.count
|
||||
|
||||
if (currentTotalCount >= totalCount) return undefined
|
||||
return page
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function isProjectPaused(project: { status: string } | null): boolean | undefined {
|
||||
return !project ? undefined : project.status === 'INACTIVE'
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
let queryClient: QueryClient | undefined
|
||||
|
||||
function getQueryClient() {
|
||||
const _queryClient =
|
||||
queryClient ??
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on 404s
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 404
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (failureCount < 3) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// For SSG and SSR always create a new queryClient
|
||||
if (typeof window === 'undefined') return _queryClient
|
||||
|
||||
// Create the query client once in the client
|
||||
if (!queryClient) queryClient = _queryClient
|
||||
return queryClient
|
||||
}
|
||||
|
||||
export function useRootQueryClient() {
|
||||
const [_queryClient] = useState(() => getQueryClient())
|
||||
|
||||
return _queryClient
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
|
||||
type LocalStorageKey = (typeof LOCAL_STORAGE_KEYS)[keyof typeof LOCAL_STORAGE_KEYS]
|
||||
type StorageType = 'local' | 'session'
|
||||
|
||||
function getStorage(storageType: StorageType) {
|
||||
return storageType === 'local' ? window.localStorage : window.sessionStorage
|
||||
}
|
||||
|
||||
export function store(storageType: StorageType, key: LocalStorageKey, value: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
const storage = getStorage(storageType)
|
||||
|
||||
try {
|
||||
storage.setItem(key as string, value)
|
||||
} catch {
|
||||
console.error(`Failed to set storage item with key "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export function retrieve(storageType: StorageType, key: LocalStorageKey): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const storage = getStorage(storageType)
|
||||
return storage.getItem(key as string)
|
||||
}
|
||||
|
||||
export function remove(storageType: StorageType, key: LocalStorageKey) {
|
||||
if (typeof window === 'undefined') return
|
||||
const storage = getStorage(storageType)
|
||||
return storage.removeItem(key as string)
|
||||
}
|
||||
|
||||
export function storeOrRemoveNull(
|
||||
storageType: StorageType,
|
||||
key: LocalStorageKey,
|
||||
value: string | null | undefined
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (value === null || value === undefined) {
|
||||
remove(storageType, key)
|
||||
} else {
|
||||
store(storageType, key, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { gotrueClient } from 'common'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const auth = gotrueClient
|
||||
|
||||
export async function getAccessToken() {
|
||||
// ignore if server-side
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await auth.getSession()
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return session?.access_token
|
||||
}
|
||||
|
||||
export function useOnLogout(callback: () => void) {
|
||||
useEffect(() => {
|
||||
const {
|
||||
data: {
|
||||
subscription: { unsubscribe },
|
||||
},
|
||||
} = auth.onAuthStateChange((event) => {
|
||||
if (event === 'SIGNED_OUT') {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [callback])
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --port 3004",
|
||||
"dev": "next dev --port 8082",
|
||||
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && next build --turbopack",
|
||||
"build:registry": "rimraf -G public/r/* && tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && shadcn build public/r/registry.json && tsx scripts/clean-registry.ts",
|
||||
"build:llms": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-llms-txt.ts",
|
||||
@@ -50,6 +50,7 @@
|
||||
"@supabase/postgrest-js": "catalog:",
|
||||
"@supabase/supa-mdx-lint": "0.2.6-alpha",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"api-types": "workspace:*",
|
||||
"@supabase/vue-blocks": "workspace:*",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "*",
|
||||
|
||||
@@ -1417,6 +1417,46 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "tanstack-db-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "TanStack DB for Next.js and Supabase",
|
||||
"description": "TanStack DB integration with Supabase collections for type-safe database operations",
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"input",
|
||||
"label",
|
||||
"sheet"
|
||||
],
|
||||
"dependencies": [
|
||||
"supa-tdb-collection@latest",
|
||||
"zod@latest",
|
||||
"@tanstack/db@latest",
|
||||
"@tanstack/react-db@latest",
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`.",
|
||||
"envVars": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "supabase-client-nextjs",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "tanstack-db-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "TanStack DB for Next.js and Supabase",
|
||||
"description": "TanStack DB integration with Supabase collections for type-safe database operations",
|
||||
"dependencies": [
|
||||
"supa-tdb-collection@latest",
|
||||
"zod@latest",
|
||||
"@tanstack/db@latest",
|
||||
"@tanstack/react-db@latest",
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"input",
|
||||
"label",
|
||||
"sheet"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
|
||||
"content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"envVars": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`."
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import socialAuthNextjs from './default/blocks/social-auth-nextjs/registry-item.
|
||||
import socialAuthReactRouter from './default/blocks/social-auth-react-router/registry-item.json' with { type: 'json' }
|
||||
import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' }
|
||||
import socialAuthTanstack from './default/blocks/social-auth-tanstack/registry-item.json' with { type: 'json' }
|
||||
import tanstackDbNextjs from './default/blocks/tanstack-db/registry-item.json' with { type: 'json' }
|
||||
import { registryItemAppend } from './utils'
|
||||
|
||||
const combine = (component: RegistryItem) => {
|
||||
@@ -52,4 +53,7 @@ export const blocks = [
|
||||
...combine(realtimeChat as RegistryItem),
|
||||
// infinite query hook is intentionally not combined with the clients since it depends on clients having database types.
|
||||
infiniteQueryHook as RegistryItem,
|
||||
|
||||
// tanstack-db is served dynamically via API route, but we register it here for the static build
|
||||
registryItemAppend(tanstackDbNextjs as RegistryItem, [nextjsClient!]),
|
||||
] as RegistryItem[]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "tanstack-db-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "TanStack DB for Next.js and Supabase",
|
||||
"description": "TanStack DB integration with Supabase collections for type-safe database operations",
|
||||
"registryDependencies": ["button", "input", "label", "sheet"],
|
||||
"dependencies": [
|
||||
"supa-tdb-collection@latest",
|
||||
"zod@latest",
|
||||
"@tanstack/db@latest",
|
||||
"@tanstack/react-db@latest"
|
||||
],
|
||||
"files": [],
|
||||
"docs": "This block provides TanStack DB integration with Supabase collections. The schemas and collections are generated based on your database schema."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ResponseError {
|
||||
code?: number
|
||||
message: string
|
||||
requestId?: string
|
||||
}
|
||||
Generated
+7
-4
@@ -172,7 +172,7 @@ importers:
|
||||
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
next-contentlayer2:
|
||||
specifier: 0.4.6
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
next-themes:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -684,7 +684,7 @@ importers:
|
||||
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
next-contentlayer2:
|
||||
specifier: 0.4.6
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
next-themes:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -1386,6 +1386,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.83.0
|
||||
version: 5.83.0(react@18.3.1)
|
||||
api-types:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-types
|
||||
axios:
|
||||
specifier: ^1.12.0
|
||||
version: 1.13.5
|
||||
@@ -1424,7 +1427,7 @@ importers:
|
||||
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
next-contentlayer2:
|
||||
specifier: 0.4.6
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
|
||||
next-themes:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -33127,7 +33130,7 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1):
|
||||
next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1):
|
||||
dependencies:
|
||||
'@contentlayer2/core': 0.4.3(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1)
|
||||
'@contentlayer2/utils': 0.4.3
|
||||
|
||||
Reference in new issue
Block a user