feat: Add TDB block (#43490)

This commit is contained in:
Ivan Vasilov authored and GitHub committed 2026-03-09 14:32:44 +01:00
1 parent ac41f6bd6f
commit 98c0cea1c3
33 files changed
+2174 -15

No files matched your search

+2 -1
View File
@@ -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"
+15 -9
View File
@@ -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&apos;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)
+18
View File
@@ -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,
})
}
+68
View File
@@ -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',
})
}
+75
View File
@@ -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'
}
+46
View File
@@ -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
}
+44
View File
@@ -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)
}
}
+36
View File
@@ -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])
}
+2 -1
View File
@@ -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": "*",
+40
View File
@@ -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`."
}
+4
View File
@@ -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."
}
+5
View File
@@ -0,0 +1,5 @@
export interface ResponseError {
code?: number
message: string
requestId?: string
}
+7 -4
View File
@@ -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