diff --git a/apps/ui-library/.env b/apps/ui-library/.env index 47f9a48d19b..649a669b5d6 100644 --- a/apps/ui-library/.env +++ b/apps/ui-library/.env @@ -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" \ No newline at end of file +NEXT_PUBLIC_API_URL="http://localhost:8080/platform" +NEXT_PUBLIC_GOTRUE_URL="http://localhost:8000/auth/v1" \ No newline at end of file diff --git a/apps/ui-library/app/Providers.tsx b/apps/ui-library/app/Providers.tsx index bec0be4ea9c..5956f800c43 100644 --- a/apps/ui-library/app/Providers.tsx +++ b/apps/ui-library/app/Providers.tsx @@ -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 ( - - - - - {children} - - - - + + + + + + {children} + + + + + ) } diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/db.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/db.ts new file mode 100644 index 00000000000..e3f396cdd48 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/db.ts @@ -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 { + 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') +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/index.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/index.ts new file mode 100644 index 00000000000..cc634455b5f --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/index.ts @@ -0,0 +1,5 @@ +export { generateSchemasContent } from './schemas' +export { generateDbContent } from './db' +export { generatePageContent } from './page' +export { generateSheetContent } from './sheet' +export { generateListContent } from './list' diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts new file mode 100644 index 00000000000..b0714319e7e --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts @@ -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} && ( + {${secondaryAccess}} + )} + ${hasCreatedAt ? `•` : ''}` + } + + 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 ( +
+ {/* Header */} +
+
+
+
+ +
+
+

${pascalName}

+

+ {items.length} ${singularName.replace(/_/g, ' ')}{items.length !== 1 ? 's' : ''} in database +

+
+
+ +
+
+ + {/* Items list */} +
+ {items.length > 0 ? ( +
+ {items.map((item) => ( +
+
+
+ +
+
+

+ {${displayAccess} || 'Untitled'} +

+
${secondaryInfo}${ + hasCreatedAt + ? ` + + Created {new Date(item.created_at).toLocaleDateString()} + ` + : ` + + {${primaryKeyAccess}.slice(0, 8)}... + ` + } +
+
+
+
+ + +
+
+ ))} +
+ ) : ( +
+ +

No ${tableName.replace(/_/g, ' ')} yet

+

Create your first ${singularName.replace(/_/g, ' ')} to get started

+ +
+ )} +
+ + {/* Create Sheet */} + <${sheetComponentName} + open={isCreateSheetOpen} + onOpenChange={setIsCreateSheetOpen} + mode="create" + /> + + {/* Edit Sheet */} + <${sheetComponentName} + open={!!editingItem} + onOpenChange={(open) => !open && setEditingItem(null)} + mode="edit" + item={editingItem} + /> +
+ ) +} +` +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/page.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/page.ts new file mode 100644 index 00000000000..20ca768d255 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/page.ts @@ -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 ( +
+ <${listComponentName} /> +
+ ) +} +` +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/schemas.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/schemas.ts new file mode 100644 index 00000000000..348f77b9068 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/schemas.ts @@ -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 { + 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`) + lines.push('') + } + + return lines.join('\n') +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/sheet.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/sheet.ts new file mode 100644 index 00000000000..baaedcc78ea --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/sheet.ts @@ -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(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 */} +
+ ${setterName}(e.target.checked)} + className="h-4 w-4 rounded border" + /> + +
` + } + + if (prop.type === 'integer' || prop.type === 'number') { + return ` {/* ${label} Field */} +
+ + ${setterName}(e.target.value ? Number(e.target.value) : '')} + placeholder="Enter ${label.toLowerCase()}" + /> +
` + } + + if (prop.enum && prop.enum.length > 0) { + const options = prop.enum + .map((e) => ` `) + .join('\n') + return ` {/* ${label} Field */} +
+ + +
` + } + + return ` {/* ${label} Field */} +
+ + ${setterName}(e.target.value)} + placeholder="Enter ${label.toLowerCase()}" + /> +
` + }) + .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 */} +
+ + +
` + }) + .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 ( + + + + {isCreate ? 'Create New ${singularPascal}' : 'Edit ${singularPascal}'} + + {isCreate + ? 'Add a new ${singularName.replace(/_/g, ' ')} to your database.' + : 'Update the ${singularName.replace(/_/g, ' ')} information.'} + + + +
+ {/* Read-only fields (only in edit mode) */} + {!isCreate && item && ( + <> +${readOnlyFormFields} + + )} + + {/* Editable fields */} +${editableFormFields} +
+ + + + + +
+
+ ) +} +` +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/route.ts b/apps/ui-library/app/api/registry/tanstack-db/route.ts new file mode 100644 index 00000000000..bc9564c6304 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/route.ts @@ -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 { + 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 } + ) + } +} diff --git a/apps/ui-library/app/api/registry/tanstack-db/types.ts b/apps/ui-library/app/api/registry/tanstack-db/types.ts new file mode 100644 index 00000000000..e6b5ee3baa7 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/types.ts @@ -0,0 +1,23 @@ +import { RegistryItem } from 'shadcn/schema' + +export interface OpenAPISchema { + definitions?: Record +} + +export interface OpenAPIDefinition { + type: string + properties?: Record + 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[number] diff --git a/apps/ui-library/app/api/registry/tanstack-db/utils.ts b/apps/ui-library/app/api/registry/tanstack-db/utils.ts new file mode 100644 index 00000000000..b4bb404c569 --- /dev/null +++ b/apps/ui-library/app/api/registry/tanstack-db/utils.ts @@ -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 }> +): 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[] { + 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 +} diff --git a/apps/ui-library/components/mdx-components.tsx b/apps/ui-library/components/mdx-components.tsx index a735f7892cd..22600e89326 100644 --- a/apps/ui-library/components/mdx-components.tsx +++ b/apps/ui-library/components/mdx-components.tsx @@ -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 { diff --git a/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx b/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx new file mode 100644 index 00000000000..b8e3d32a9a2 --- /dev/null +++ b/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx @@ -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({ + 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(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 ( + { + setOpen(value) + if (!value) setSearch('') + }} + > + + + + + + setSearch('')} + /> + + + {isLoading ? ( +
+ + +
+ ) : ( + <> + {search.length > 0 && options.length === 0 && ( +

+ No {name}s found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {options.map((option) => ( + { + setOpen(false) + onSelectOption(selectedValue) + }} + className="cursor-pointer" + > + + {option.displayName} + + ))} +
+ {hasNextPage && } + + + )} + + + + + + ) +} diff --git a/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx b/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx new file mode 100644 index 00000000000..0ea514cdd79 --- /dev/null +++ b/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx @@ -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(null) + const [selectedProject, setSelectedProject] = useState(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 ( +
+ + {isApiKeysPending && selectedProject && ( +

Loading API keys...

+ )} + {isApiKeysError && selectedProject && ( +

Failed to load API keys for this project.

+ )} + {missingAnonKey && selectedProject && ( +
+ This project does not have an anon key. Please enable the anon key in your project's + API settings to use this block. +
+ )} +
+ ) +} diff --git a/apps/ui-library/components/tanstack-db-generator/index.tsx b/apps/ui-library/components/tanstack-db-generator/index.tsx new file mode 100644 index 00000000000..586cfd06576 --- /dev/null +++ b/apps/ui-library/components/tanstack-db-generator/index.tsx @@ -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 = { + 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 ( +
+ + Loading... +
+ ) + } + + if (!isLoggedIn) { + return ( +
+

+ + Log in + {' '} + to your Supabase account to generate installation commands for your project. +

+
+ ) + } + + return ( +
+
+ + +
+ + {resolvedProject && ( + <> +
+ Copy the command below to install the block. +
+ + +
+
+ + {(Object.keys(commands) as PackageManager[]).map((manager) => ( + + {manager} + + ))} + + + {(Object.keys(commands) as PackageManager[]).map((manager) => ( + +
+
+ $ + {commands[manager]} +
+ +
+
+ ))} +
+
+
+ + )} +
+ ) +} diff --git a/apps/ui-library/components/tanstack-db-generator/utils.ts b/apps/ui-library/components/tanstack-db-generator/utils.ts new file mode 100644 index 00000000000..dcf03909a1b --- /dev/null +++ b/apps/ui-library/components/tanstack-db-generator/utils.ts @@ -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(/(? = { + readonly [P in keyof T]: DeepReadonly +} + +export function toDisplayNameOrgProject( + org: DeepReadonly, + project: DeepReadonly +) { + return `${org.name} / ${project.name}` +} + +export function toOrgProjectValue( + org: DeepReadonly, + project: DeepReadonly +) { + 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] + } +} diff --git a/apps/ui-library/content/docs/nextjs/tanstack-db.mdx b/apps/ui-library/content/docs/nextjs/tanstack-db.mdx new file mode 100644 index 00000000000..4cab80febc0 --- /dev/null +++ b/apps/ui-library/content/docs/nextjs/tanstack-db.mdx @@ -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. + + + + + Your credentials are only used to fetch your database schema. They are not stored or sent to any + server other than your Supabase project. + + +## 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) diff --git a/apps/ui-library/hooks/useDebounce.ts b/apps/ui-library/hooks/useDebounce.ts new file mode 100644 index 00000000000..a90c6efaa69 --- /dev/null +++ b/apps/ui-library/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react' + +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L239 +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/ui-library/hooks/useIntersectionObserver.ts b/apps/ui-library/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000000..9a6b3e7dd04 --- /dev/null +++ b/apps/ui-library/hooks/useIntersectionObserver.ts @@ -0,0 +1,39 @@ +import { RefCallback, useCallback, useRef, useState } from 'react' + +// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L512 +export function useIntersectionObserver( + options: { + root?: Element | Document | null + rootMargin?: string + threshold?: number | number[] + } = {} +): [RefCallback, IntersectionObserverEntry | null] { + const { threshold = 1, root = null, rootMargin = '0px' } = options + const [entry, setEntry] = useState(null) + + const previousObserver = useRef(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] +} diff --git a/apps/ui-library/lib/fetch/fetchWrappers.ts b/apps/ui-library/lib/fetch/fetchWrappers.ts new file mode 100644 index 00000000000..00b7d6af083 --- /dev/null +++ b/apps/ui-library/lib/fetch/fetchWrappers.ts @@ -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({ + 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 diff --git a/apps/ui-library/lib/fetch/organizations.ts b/apps/ui-library/lib/fetch/organizations.ts new file mode 100644 index 00000000000..aded75bd187 --- /dev/null +++ b/apps/ui-library/lib/fetch/organizations.ts @@ -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> + +export const organizationsQueryOptions = ( + { enabled = true }: { enabled?: boolean } = { enabled: true } +) => { + return queryOptions({ + queryKey: organizationKeys.list(), + queryFn: ({ signal }) => getOrganizations(signal), + enabled, + }) +} diff --git a/apps/ui-library/lib/fetch/projectApi.ts b/apps/ui-library/lib/fetch/projectApi.ts new file mode 100644 index 00000000000..0fdd9b402a2 --- /dev/null +++ b/apps/ui-library/lib/fetch/projectApi.ts @@ -0,0 +1,68 @@ +import { queryOptions } from '@tanstack/react-query' + +import { get } from './fetchWrappers' + +export interface ProjectApiVariables { + projectRef?: string +} + +export type ProjectKeys = Awaited> + +export type ProjectSettings = Awaited> + +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', + }) +} diff --git a/apps/ui-library/lib/fetch/projects.ts b/apps/ui-library/lib/fetch/projects.ts new file mode 100644 index 00000000000..ff2c035284f --- /dev/null +++ b/apps/ui-library/lib/fetch/projects.ts @@ -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 +) { + 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> + +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' +} diff --git a/apps/ui-library/lib/fetch/queryClient.ts b/apps/ui-library/lib/fetch/queryClient.ts new file mode 100644 index 00000000000..d28330e4b59 --- /dev/null +++ b/apps/ui-library/lib/fetch/queryClient.ts @@ -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 +} diff --git a/apps/ui-library/lib/storage.ts b/apps/ui-library/lib/storage.ts new file mode 100644 index 00000000000..526eca9ee28 --- /dev/null +++ b/apps/ui-library/lib/storage.ts @@ -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) + } +} diff --git a/apps/ui-library/lib/userAuth.ts b/apps/ui-library/lib/userAuth.ts new file mode 100644 index 00000000000..12fec885fe7 --- /dev/null +++ b/apps/ui-library/lib/userAuth.ts @@ -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]) +} diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 68b5e0253b4..2ed3dac2c1c 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -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": "*", diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index a757ca0ae49..969636bc3a5 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -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", diff --git a/apps/ui-library/public/r/tanstack-db-nextjs.json b/apps/ui-library/public/r/tanstack-db-nextjs.json new file mode 100644 index 00000000000..aafa10ab5d0 --- /dev/null +++ b/apps/ui-library/public/r/tanstack-db-nextjs.json @@ -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`." +} \ No newline at end of file diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts index 417af327346..d54c7bcd59e 100644 --- a/apps/ui-library/registry/blocks.ts +++ b/apps/ui-library/registry/blocks.ts @@ -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[] diff --git a/apps/ui-library/registry/default/blocks/tanstack-db/registry-item.json b/apps/ui-library/registry/default/blocks/tanstack-db/registry-item.json new file mode 100644 index 00000000000..586a3eb244a --- /dev/null +++ b/apps/ui-library/registry/default/blocks/tanstack-db/registry-item.json @@ -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." +} diff --git a/apps/ui-library/types/fetch.ts b/apps/ui-library/types/fetch.ts new file mode 100644 index 00000000000..776c7a1fa09 --- /dev/null +++ b/apps/ui-library/types/fetch.ts @@ -0,0 +1,5 @@ +export interface ResponseError { + code?: number + message: string + requestId?: string +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5988ae0e1f8..5180a4023a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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