Add project selector to the page.

This commit is contained in:
Ivan Vasilov committed 2026-03-06 15:04:47 +01:00
1 parent 9b67c67b67
commit 7aaeb796c7
20 files changed
+984 -232

No files matched your search

+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>
)
}
@@ -115,13 +115,17 @@ ${selectFields}
<p className="font-medium truncate">
{item.${displayField} || 'Untitled'}
</p>
<div className="flex items-center gap-3 text-muted-foreground text-xs">${secondaryInfo}${hasCreatedAt ? `
<div className="flex items-center gap-3 text-muted-foreground text-xs">${secondaryInfo}${
hasCreatedAt
? `
<span>
Created {new Date(item.created_at).toLocaleDateString()}
</span>` : `
</span>`
: `
<span className="font-mono truncate max-w-[120px]" title={item.${primaryKey}}>
{item.${primaryKey}.slice(0, 8)}...
</span>`}
</span>`
}
</div>
</div>
</div>
@@ -1,214 +0,0 @@
'use client'
import { Check, Copy, Loader2 } from 'lucide-react'
import { useState } from 'react'
import {
Button,
cn,
copyToClipboard,
Input_Shadcn_ as Input,
Label_Shadcn_ as Label,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
} from 'ui'
import { useLocalStorage } from './use-local-storage'
type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
const LOCAL_STORAGE_KEY = 'package-manager-tanstack-db'
const getBaseUrl = () => {
if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
return 'https://supabase.com/ui'
} else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
} else {
return 'http://localhost:3004/ui'
}
}
export function TanstackDBGenerator() {
const [projectRef, setProjectRef] = useState('')
const [anonKey, setAnonKey] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [isGenerated, setIsGenerated] = useState(false)
const [hasCopied, setHasCopied] = useState(false)
const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
const baseUrl = getBaseUrl()
const apiUrl = `${baseUrl}${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/registry/tanstack-db?ref=${encodeURIComponent(projectRef)}&anonKey=${encodeURIComponent(anonKey)}`
const commands: Record<PackageManager, string> = {
npm: `npx shadcn@latest add "${apiUrl}"`,
pnpm: `pnpm dlx shadcn@latest add "${apiUrl}"`,
yarn: `yarn dlx shadcn@latest add "${apiUrl}"`,
bun: `bunx --bun shadcn@latest add "${apiUrl}"`,
}
const handleValidate = async () => {
if (!projectRef.trim() || !anonKey.trim()) {
setValidationError('Please enter both Project Ref and Anon Key')
return
}
setIsValidating(true)
setValidationError(null)
try {
// Test the connection by fetching the OpenAPI spec
const response = await fetch(`https://${projectRef}.supabase.co/rest/v1/`, {
headers: {
apikey: anonKey,
},
})
if (!response.ok) {
throw new Error(`Invalid credentials or project not found (${response.status})`)
}
const data = await response.json()
if (!data.definitions || Object.keys(data.definitions).length === 0) {
setValidationError('No tables found in your database. Create some tables first.')
return
}
setIsGenerated(true)
} catch (error) {
setValidationError(
error instanceof Error ? error.message : 'Failed to connect to your Supabase project'
)
} finally {
setIsValidating(false)
}
}
const handleCopy = (command: string) => {
copyToClipboard(command)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
const handleReset = () => {
setIsGenerated(false)
setProjectRef('')
setAnonKey('')
setValidationError(null)
}
return (
<div className="mt-6 space-y-4">
{!isGenerated ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="project-ref">Project Ref</Label>
<Input
id="project-ref"
placeholder="your-project-ref"
value={projectRef}
onChange={(e) => setProjectRef(e.target.value)}
className="font-mono"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Found in your project settings or URL (e.g., abcdefghijklmnop)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="anon-key">Anon Key</Label>
<Input
id="anon-key"
placeholder="eyJhbGciOiJIUzI1NiIs..."
value={anonKey}
onChange={(e) => setAnonKey(e.target.value)}
className="font-mono"
type="password"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Found in your project&apos;s API settings
</p>
</div>
</div>
{validationError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
{validationError}
</div>
)}
<Button
onClick={handleValidate}
disabled={isValidating || !projectRef.trim() || !anonKey.trim()}
className="w-full sm:w-auto"
>
{isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validating...
</>
) : (
'Generate Installation Command'
)}
</Button>
</>
) : (
<>
<div className="rounded-md bg-brand-400/10 border border-brand-400/20 p-3 text-sm text-brand-600">
Successfully connected to your Supabase project. Copy the command below to install the
block.
</div>
<Tabs value={packageManager} onValueChange={setPackageManager} className="w-full">
<div className="w-full group relative rounded-lg bg-surface-200 dark:bg-surface-100 px-4 py-2 overflow-hidden">
<div className="flex flex-col">
<TabsList className="gap-2 relative mb-2 z-10">
{(Object.keys(commands) as PackageManager[]).map((manager) => (
<TabsTrigger key={manager} value={manager} className="text-xs">
{manager}
</TabsTrigger>
))}
</TabsList>
{(Object.keys(commands) as PackageManager[]).map((manager) => (
<TabsContent key={manager} value={manager} className="m-0">
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-sm text-foreground relative z-10 overflow-x-auto">
<span className="mr-2 text-[#888] select-none">$</span>
<span className="whitespace-nowrap">{commands[manager]}</span>
</div>
<Button
size="small"
type="outline"
className={cn(
'relative z-10 h-6 w-6 text-foreground-muted hover:bg-surface-100 hover:text-foreground p-0 shrink-0'
)}
onClick={() => handleCopy(commands[manager])}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<Check className="h-3 w-3 text-brand-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</TabsContent>
))}
</div>
</div>
</Tabs>
<Button type="outline" onClick={handleReset} className="w-full sm:w-auto">
Generate for a different project
</Button>
</>
)}
</div>
)
}
@@ -0,0 +1,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,180 @@
'use client'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useIsLoggedIn, useIsUserLoading, LOCAL_STORAGE_KEYS } from 'common'
import { useEffect, useMemo, useState } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
import { organizationsQueryOptions } from '@/lib/fetch/organizations'
import { projectsInfiniteQueryOptions, type ProjectInfoInfinite } from '@/lib/fetch/projects'
import { projectKeysQueryOptions } from '@/lib/fetch/projectApi'
import { retrieve, storeOrRemoveNull } from '@/lib/storage'
import { type Org, fromOrgProjectValue, toDisplayNameOrgProject, toOrgProjectValue } from './utils'
import { ComboBox, type ComboBoxOption } from './ComboBox'
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 [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 key =
apiKeysData.find((k) => k.type === 'publishable')?.api_key ??
apiKeysData.find((k) => k.type === 'legacy' && k.id === 'anon')?.api_key
if (key) {
onProjectResolved({ projectRef: selectedProject.ref, anonKey: key })
} else {
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>
)}
</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]
}
}
@@ -26,7 +26,7 @@ This block installs the following files:
| `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 |
| `lib/supabase/middleware.ts` | Middleware for session handling |
| `app/providers.tsx` | Provider component for TanStack DB |
## Getting started
+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 { get } from './fetchWrappers'
import { queryOptions } from '@tanstack/react-query'
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) {
if (typeof window === 'undefined') return
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": "*",
+5
View File
@@ -0,0 +1,5 @@
export interface ResponseError {
code?: number
message: string
requestId?: string
}
+7 -4
View File
@@ -167,7 +167,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)
@@ -679,7 +679,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)
@@ -1381,6 +1381,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
@@ -1419,7 +1422,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)
@@ -33175,7 +33178,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