mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
add tanstack db generator.
This commit is contained in:
1 parent
fc06ffc901
commit
c69d96fbd8
18 files changed
+1401
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { findPrimaryKeys, 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 { createSupabaseCollection } from 'supabase-collection'",
|
||||
"import { createClient } from '@/lib/supabase/client'",
|
||||
"import { getQueryClient } from '@/lib/query-client'",
|
||||
'import {',
|
||||
...tableNames.map((name) => ` ${toCamelCase(name)}Schema,`),
|
||||
"} from './schemas'",
|
||||
'',
|
||||
'const supabase = createClient()',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const [tableName, definition] of Object.entries(definitions)) {
|
||||
// Skip internal PostgREST tables
|
||||
if (tableName.startsWith('_')) continue
|
||||
|
||||
const collectionName = `${toCamelCase(tableName)}Collection`
|
||||
const schemaName = `${toCamelCase(tableName)}Schema`
|
||||
const properties = definition.properties || {}
|
||||
const primaryKeys = findPrimaryKeys(properties)
|
||||
|
||||
// Generate getKey function
|
||||
let getKeyFn: string
|
||||
if (primaryKeys.length === 1) {
|
||||
getKeyFn = `(item) => item.${primaryKeys[0]}`
|
||||
} else if (primaryKeys.length > 1) {
|
||||
const keyParts = primaryKeys.map((k) => `\${item.${k}}`).join('-')
|
||||
getKeyFn = `(item) => \`${keyParts}\``
|
||||
} else {
|
||||
// Fallback to JSON stringify if no keys found
|
||||
getKeyFn = '(item) => JSON.stringify(item)'
|
||||
}
|
||||
|
||||
// Generate where function
|
||||
let whereFn: string
|
||||
if (primaryKeys.length === 1) {
|
||||
whereFn = `(query, item) => query.eq('${primaryKeys[0]}', item.${primaryKeys[0]})`
|
||||
} else if (primaryKeys.length > 1) {
|
||||
const whereParts = primaryKeys.map((k) => `.eq('${k}', item.${k})`).join('')
|
||||
whereFn = `(query, item) => query${whereParts}`
|
||||
} else {
|
||||
whereFn = '(query, item) => query'
|
||||
}
|
||||
|
||||
lines.push(`export const ${collectionName} = createSupabaseCollection({`)
|
||||
lines.push(` tableName: '${tableName}',`)
|
||||
lines.push(` getKey: ${getKeyFn},`)
|
||||
lines.push(` where: ${whereFn},`)
|
||||
lines.push(` schema: ${schemaName},`)
|
||||
lines.push(' queryClient: getQueryClient(),')
|
||||
lines.push(' supabase: supabase as any,')
|
||||
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,187 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { findPrimaryKeys, toCamelCase, toPascalCase, toSingular } from '../utils'
|
||||
|
||||
// Generate list component content
|
||||
export function generateListContent(tableName: string, definition: OpenAPIDefinition): string {
|
||||
const pascalName = toPascalCase(tableName)
|
||||
const singularName = toSingular(tableName)
|
||||
const singularPascal = toPascalCase(singularName)
|
||||
const camelName = toCamelCase(tableName)
|
||||
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) => ` ${name}: item.${name},`).join('\n')
|
||||
|
||||
// Determine if we have created_at for display
|
||||
const hasCreatedAt = properties['created_at'] !== undefined
|
||||
|
||||
// Generate secondary info display
|
||||
let secondaryInfo = ''
|
||||
if (secondaryField) {
|
||||
secondaryInfo = `
|
||||
{item.${secondaryField} && (
|
||||
<span className="truncate">{item.${secondaryField}}</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 }) => item.${displayField}, '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={item.${primaryKey}}
|
||||
>
|
||||
<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">
|
||||
{item.${displayField} || '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={item.${primaryKey}}>
|
||||
{item.${primaryKey}.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(item.${primaryKey})}
|
||||
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,32 @@
|
||||
import { toCamelCase, toPascalCase } from '../utils'
|
||||
|
||||
// Generate page.tsx content
|
||||
export function generatePageContent(tableName: string): string {
|
||||
const pascalName = toPascalCase(tableName)
|
||||
const camelName = toCamelCase(tableName)
|
||||
const collectionName = `${camelName}Collection`
|
||||
const listComponentName = `${pascalName}List`
|
||||
|
||||
return `import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
|
||||
|
||||
import { ${collectionName} } from '@/lib/db'
|
||||
import { getQueryClient } from '@/lib/query-client'
|
||||
|
||||
import { ${listComponentName} } from './${tableName}-list'
|
||||
|
||||
export default async function ${pascalName}Page() {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
// Prefetch ${camelName} on the server
|
||||
await queryClient.prefetchQuery(${collectionName}.listQueryOptions())
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<div className="container mx-auto max-w-4xl py-8">
|
||||
<${listComponentName} />
|
||||
</div>
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { openApiTypeToZod, 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 typeName = toPascalCase(tableName)
|
||||
const schemaName = `${toCamelCase(tableName)}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(` ${propName}: ${zodType},`)
|
||||
}
|
||||
|
||||
lines.push('})')
|
||||
lines.push('')
|
||||
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { OpenAPIDefinition } from '../types'
|
||||
import { findPrimaryKeys, toCamelCase, toLabel, toPascalCase, toSingular } from '../utils'
|
||||
|
||||
// Generate sheet component content
|
||||
export function generateSheetContent(tableName: string, definition: OpenAPIDefinition): string {
|
||||
const pascalName = toPascalCase(tableName)
|
||||
const singularName = toSingular(tableName)
|
||||
const singularPascal = toPascalCase(singularName)
|
||||
const camelName = toCamelCase(tableName)
|
||||
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 stateName = toCamelCase(name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` const [${stateName}, set${toPascalCase(name)}] = useState(false)`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` const [${stateName}, set${toPascalCase(name)}] = useState<number | ''>(0)`
|
||||
}
|
||||
return ` const [${stateName}, set${toPascalCase(name)}] = useState('')`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate useEffect reset
|
||||
const useEffectReset = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const stateName = toCamelCase(name)
|
||||
const setterName = `set${toPascalCase(name)}`
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${setterName}(item?.${name} ?? false)`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${setterName}(item?.${name} ?? 0)`
|
||||
}
|
||||
return ` ${setterName}(item?.${name} ?? '')`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate form fields for editable fields
|
||||
const editableFormFields = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const stateName = toCamelCase(name)
|
||||
const setterName = `set${toPascalCase(name)}`
|
||||
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 displayValue = isTimestamp ? `new Date(item.${name}).toLocaleString()` : `item.${name}`
|
||||
|
||||
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 = [
|
||||
` ${primaryKey}: crypto.randomUUID(),`,
|
||||
...editableFields.map(([name, prop]) => {
|
||||
const stateName = toCamelCase(name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${name}: ${stateName},`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${name}: ${stateName} || 0,`
|
||||
}
|
||||
return ` ${name}: ${stateName},`
|
||||
}),
|
||||
// Add timestamps if they exist
|
||||
...readOnlyFields
|
||||
.filter(([name]) => name === 'created_at')
|
||||
.map(() => ` created_at: new Date().toISOString(),`),
|
||||
].join('\n')
|
||||
|
||||
// Generate update data
|
||||
const updateData = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const stateName = toCamelCase(name)
|
||||
if (prop.type === 'boolean') {
|
||||
return ` ${name}: ${stateName},`
|
||||
}
|
||||
if (prop.type === 'integer' || prop.type === 'number') {
|
||||
return ` ${name}: ${stateName} || 0,`
|
||||
}
|
||||
return ` ${name}: ${stateName},`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// Generate reset states
|
||||
const resetStates = editableFields
|
||||
.map(([name, prop]) => {
|
||||
const setterName = `set${toPascalCase(name)}`
|
||||
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 stateName = toCamelCase(name)
|
||||
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'
|
||||
const title = isCreate ? 'Create New ${singularPascal}' : 'Edit ${singularPascal}'
|
||||
const description = isCreate
|
||||
? 'Add a new ${singularName.replace(/_/g, ' ')} to your database.'
|
||||
: 'Update the ${singularName.replace(/_/g, ' ')} information.'
|
||||
const submitLabel = isCreate ? 'Create ${singularPascal}' : 'Save Changes'
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
${useEffectReset}
|
||||
}
|
||||
}, [open, item])
|
||||
|
||||
const handleSubmit = () => {
|
||||
${validationCheck ? ` if (${validationCheck}) return\n` : ''}
|
||||
if (isCreate) {
|
||||
${collectionName}.insert({
|
||||
${insertData}
|
||||
})
|
||||
} else if (item) {
|
||||
${collectionName}.update([item.${primaryKey}], (prev) => ({
|
||||
...prev,
|
||||
${updateData}
|
||||
}))
|
||||
}
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
${resetStates}
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</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}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
import { RegistryItem } from 'shadcn/schema'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
import { OpenAPISchema, RegistryFile } from './types'
|
||||
import { toSingular } from './utils'
|
||||
import {
|
||||
generateSchemasContent,
|
||||
generateDbContent,
|
||||
generatePageContent,
|
||||
generateSheetContent,
|
||||
generateListContent,
|
||||
} from './generators'
|
||||
|
||||
// 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 }
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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('_'))
|
||||
const firstTableName = tableNames[0]
|
||||
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)
|
||||
|
||||
dynamicFiles.push(
|
||||
{
|
||||
path: `app/${firstTableName}/page.tsx`,
|
||||
content: generatePageContent(firstTableName),
|
||||
type: 'registry:page',
|
||||
target: `app/${firstTableName}/page.tsx`,
|
||||
},
|
||||
{
|
||||
path: `app/${firstTableName}/${singularTableName}-sheet.tsx`,
|
||||
content: generateSheetContent(firstTableName, firstTableDefinition),
|
||||
type: 'registry:component',
|
||||
target: `app/${firstTableName}/${singularTableName}-sheet.tsx`,
|
||||
},
|
||||
{
|
||||
path: `app/${firstTableName}/${firstTableName}-list.tsx`,
|
||||
content: generateListContent(firstTableName, firstTableDefinition),
|
||||
type: 'registry:component',
|
||||
target: `app/${firstTableName}/${firstTableName}-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_OR_ANON_KEY: anonKey,
|
||||
},
|
||||
}
|
||||
|
||||
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,105 @@
|
||||
import { OpenAPIProperty } from './types'
|
||||
|
||||
// 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}'`).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,212 @@
|
||||
'use client'
|
||||
|
||||
import { Check, Copy, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input_Shadcn_ as Input,
|
||||
Label_Shadcn_ as Label,
|
||||
Tabs_Shadcn_ as Tabs,
|
||||
TabsContent_Shadcn_ as TabsContent,
|
||||
TabsList_Shadcn_ as TabsList,
|
||||
TabsTrigger_Shadcn_ as TabsTrigger,
|
||||
cn,
|
||||
copyToClipboard,
|
||||
} from 'ui'
|
||||
|
||||
import { useLocalStorage } from './use-local-storage'
|
||||
|
||||
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 [projectRef, setProjectRef] = useState('')
|
||||
const [anonKey, setAnonKey] = useState('')
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [isGenerated, setIsGenerated] = useState(false)
|
||||
const [hasCopied, setHasCopied] = useState(false)
|
||||
const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiUrl = `${baseUrl}/api/registry/tanstack-db?ref=${encodeURIComponent(projectRef)}&anonKey=${encodeURIComponent(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 handleValidate = async () => {
|
||||
if (!projectRef.trim() || !anonKey.trim()) {
|
||||
setValidationError('Please enter both Project Ref and Anon Key')
|
||||
return
|
||||
}
|
||||
|
||||
setIsValidating(true)
|
||||
setValidationError(null)
|
||||
|
||||
try {
|
||||
// Test the connection by fetching the OpenAPI spec
|
||||
const response = await fetch(`https://${projectRef}.supabase.co/rest/v1/`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid credentials or project not found (${response.status})`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.definitions || Object.keys(data.definitions).length === 0) {
|
||||
setValidationError('No tables found in your database. Create some tables first.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsGenerated(true)
|
||||
} catch (error) {
|
||||
setValidationError(
|
||||
error instanceof Error ? error.message : 'Failed to connect to your Supabase project'
|
||||
)
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (command: string) => {
|
||||
copyToClipboard(command)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setIsGenerated(false)
|
||||
setProjectRef('')
|
||||
setAnonKey('')
|
||||
setValidationError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
{!isGenerated ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-ref">Project Ref</Label>
|
||||
<Input
|
||||
id="project-ref"
|
||||
placeholder="your-project-ref"
|
||||
value={projectRef}
|
||||
onChange={(e) => setProjectRef(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Found in your project settings or URL (e.g., abcdefghijklmnop)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anon-key">Anon Key</Label>
|
||||
<Input
|
||||
id="anon-key"
|
||||
placeholder="eyJhbGciOiJIUzI1NiIs..."
|
||||
value={anonKey}
|
||||
onChange={(e) => setAnonKey(e.target.value)}
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Found in your project's API settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={isValidating || !projectRef.trim() || !anonKey.trim()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Validating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Installation Command'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md bg-brand-400/10 border border-brand-400/20 p-3 text-sm text-brand-600">
|
||||
Successfully connected to your Supabase project. 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>
|
||||
|
||||
<Button type="outline" onClick={handleReset} className="w-full sm:w-auto">
|
||||
Generate for a different project
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: TanStack DB
|
||||
description: Type-safe database collections with TanStack Query and Supabase
|
||||
---
|
||||
|
||||
This block provides TanStack Query integration with Supabase collections for type-safe database operations. It generates Zod schemas and collection configurations based on your database schema.
|
||||
|
||||
## 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/query-client.ts` | TanStack Query client configuration with SSR support |
|
||||
| `app/providers.tsx` | React Query provider component |
|
||||
| `lib/supabase/client.ts` | Browser Supabase client |
|
||||
| `lib/supabase/server.ts` | Server-side Supabase client |
|
||||
| `lib/supabase/middleware.ts` | Middleware for session handling |
|
||||
|
||||
## 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_OR_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Wrap your app with the Providers component
|
||||
|
||||
Update your root layout to include the Providers component:
|
||||
|
||||
```tsx
|
||||
import { Providers } from '@/app/providers'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Using collections in your components
|
||||
|
||||
Each table in your database gets a corresponding collection. Here's how to use them:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { usersCollection } from '@/lib/db'
|
||||
|
||||
export function UserList() {
|
||||
const { data: users, isLoading } = usersCollection.useList()
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
|
||||
return <ul>{users?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>
|
||||
}
|
||||
```
|
||||
|
||||
### Available collection methods
|
||||
|
||||
Each collection provides the following methods:
|
||||
|
||||
| Method | Description |
|
||||
| ------------- | -------------------------- |
|
||||
| `useList()` | Fetch all items |
|
||||
| `useGet(key)` | Fetch a single item by key |
|
||||
| `useCreate()` | Create a new item |
|
||||
| `useUpdate()` | Update an existing item |
|
||||
| `useDelete()` | Delete an item |
|
||||
|
||||
### Server-side data fetching
|
||||
|
||||
For server components, you can prefetch data using the query client:
|
||||
|
||||
```tsx
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
|
||||
|
||||
import { usersCollection } from '@/lib/db'
|
||||
import { getQueryClient } from '@/lib/query-client'
|
||||
|
||||
export default async function UsersPage() {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
// Prefetch users on the server
|
||||
await queryClient.prefetchQuery(usersCollection.listQueryOptions())
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<UserList />
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Further reading
|
||||
|
||||
- [TanStack Query documentation](https://tanstack.com/query/latest)
|
||||
- [Supabase JavaScript client](https://supabase.com/docs/reference/javascript)
|
||||
- [Generating TypeScript types](https://supabase.com/docs/guides/api/rest/generating-types)
|
||||
@@ -1417,6 +1417,49 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$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 Query integration with Supabase collections for type-safe database operations",
|
||||
"registryDependencies": [],
|
||||
"dependencies": [
|
||||
"@tanstack/react-query@latest",
|
||||
"github:ivasilov/supabase-collection",
|
||||
"zod@latest",
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "app/providers.tsx"
|
||||
},
|
||||
{
|
||||
"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_OR_ANON_KEY`.",
|
||||
"envVars": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "supabase-client-nextjs",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$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 Query integration with Supabase collections for type-safe database operations",
|
||||
"dependencies": [
|
||||
"@tanstack/react-query@latest",
|
||||
"github:ivasilov/supabase-collection",
|
||||
"zod@latest",
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
|
||||
"content": "import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'\nimport { cache } from 'react'\n\nfunction makeQueryClient() {\n return new QueryClient({\n defaultOptions: {\n queries: {\n staleTime: 60 * 1000,\n },\n dehydrate: {\n // include pending queries in dehydration\n shouldDehydrateQuery: (query) =>\n defaultShouldDehydrateQuery(query) || query.state.status === 'pending',\n shouldRedactErrors: (error) => {\n // We should not catch Next.js server errors\n // as that's how Next.js detects dynamic pages\n // so we cannot redact them.\n // Next.js also automatically redacts errors for us\n // with better digests.\n return false\n },\n },\n },\n })\n}\n\nlet browserQueryClient: QueryClient | undefined = undefined\nconst serverQueryClient = cache(() => makeQueryClient())\n\nexport function getQueryClient() {\n if (isServer) {\n return serverQueryClient()\n } else {\n // Browser: make a new query client if we don't already have one\n // This is very important, so we don't re-make a new client if React\n // suspends during the initial render. This may not be needed if we\n // have a suspense boundary BELOW the creation of the query client\n if (!browserQueryClient) browserQueryClient = makeQueryClient()\n return browserQueryClient\n }\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
|
||||
"content": "'use client'\n\nimport { QueryClientProvider } from '@tanstack/react-query'\n\nimport { getQueryClient } from '@/registry/default/blocks/tanstack-db/lib/query-client'\n\nexport function Providers({ children }: { children: React.ReactNode }) {\n // NOTE: Avoid useState when initializing the query client if you don't\n // have a suspense boundary between this and the code that may\n // suspend because React will throw away the client on the initial\n // render if it suspends and there is no boundary\n const queryClient = getQueryClient()\n\n return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n}\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/providers.tsx"
|
||||
},
|
||||
{
|
||||
"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_OR_ANON_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_OR_ANON_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_OR_ANON_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_OR_ANON_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_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,15 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { getQueryClient } from '@/registry/default/blocks/tanstack-db/lib/query-client'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// NOTE: Avoid useState when initializing the query client if you don't
|
||||
// have a suspense boundary between this and the code that may
|
||||
// suspend because React will throw away the client on the initial
|
||||
// render if it suspends and there is no boundary
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'
|
||||
import { cache } from 'react'
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
// include pending queries in dehydration
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
||||
shouldRedactErrors: (error) => {
|
||||
// We should not catch Next.js server errors
|
||||
// as that's how Next.js detects dynamic pages
|
||||
// so we cannot redact them.
|
||||
// Next.js also automatically redacts errors for us
|
||||
// with better digests.
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined
|
||||
const serverQueryClient = cache(() => makeQueryClient())
|
||||
|
||||
export function getQueryClient() {
|
||||
if (isServer) {
|
||||
return serverQueryClient()
|
||||
} else {
|
||||
// Browser: make a new query client if we don't already have one
|
||||
// This is very important, so we don't re-make a new client if React
|
||||
// suspends during the initial render. This may not be needed if we
|
||||
// have a suspense boundary BELOW the creation of the query client
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient()
|
||||
return browserQueryClient
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$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 Query integration with Supabase collections for type-safe database operations",
|
||||
"registryDependencies": [],
|
||||
"dependencies": [
|
||||
"@tanstack/react-query@latest",
|
||||
"github:ivasilov/supabase-collection",
|
||||
"zod@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/lib/query-client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/tanstack-db/app/providers.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "app/providers.tsx"
|
||||
}
|
||||
],
|
||||
"docs": "This block provides TanStack Query integration with Supabase collections. The schemas and collections are generated based on your database schema."
|
||||
}
|
||||
Reference in new issue
Block a user