From 7aaeb796c7bd00edcdc277a729cc959a031dda5d Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 6 Mar 2026 15:04:47 +0100 Subject: [PATCH] Add project selector to the page. --- apps/ui-library/app/Providers.tsx | 24 +- .../registry/tanstack-db/_generators/list.ts | 10 +- .../components/tanstack-db-generator.tsx | 214 ------------------ .../tanstack-db-generator/ComboBox.tsx | 163 +++++++++++++ .../tanstack-db-generator/ProjectPicker.tsx | 180 +++++++++++++++ .../tanstack-db-generator/index.tsx | 152 +++++++++++++ .../components/tanstack-db-generator/utils.ts | 55 +++++ .../content/docs/nextjs/tanstack-db.mdx | 2 +- apps/ui-library/hooks/useDebounce.ts | 18 ++ .../hooks/useIntersectionObserver.ts | 39 ++++ apps/ui-library/lib/fetch/fetchWrappers.ts | 44 ++++ apps/ui-library/lib/fetch/organizations.ts | 27 +++ apps/ui-library/lib/fetch/projectApi.ts | 68 ++++++ apps/ui-library/lib/fetch/projects.ts | 75 ++++++ apps/ui-library/lib/fetch/queryClient.ts | 46 ++++ apps/ui-library/lib/storage.ts | 44 ++++ apps/ui-library/lib/userAuth.ts | 36 +++ apps/ui-library/package.json | 3 +- apps/ui-library/types/fetch.ts | 5 + pnpm-lock.yaml | 11 +- 20 files changed, 984 insertions(+), 232 deletions(-) delete mode 100644 apps/ui-library/components/tanstack-db-generator.tsx create mode 100644 apps/ui-library/components/tanstack-db-generator/ComboBox.tsx create mode 100644 apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx create mode 100644 apps/ui-library/components/tanstack-db-generator/index.tsx create mode 100644 apps/ui-library/components/tanstack-db-generator/utils.ts create mode 100644 apps/ui-library/hooks/useDebounce.ts create mode 100644 apps/ui-library/hooks/useIntersectionObserver.ts create mode 100644 apps/ui-library/lib/fetch/fetchWrappers.ts create mode 100644 apps/ui-library/lib/fetch/organizations.ts create mode 100644 apps/ui-library/lib/fetch/projectApi.ts create mode 100644 apps/ui-library/lib/fetch/projects.ts create mode 100644 apps/ui-library/lib/fetch/queryClient.ts create mode 100644 apps/ui-library/lib/storage.ts create mode 100644 apps/ui-library/lib/userAuth.ts create mode 100644 apps/ui-library/types/fetch.ts 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/list.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts index cffeeb72284..2076171390d 100644 --- a/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts +++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts @@ -115,13 +115,17 @@ ${selectFields}

{item.${displayField} || 'Untitled'}

-
${secondaryInfo}${hasCreatedAt ? ` +
${secondaryInfo}${ + hasCreatedAt + ? ` Created {new Date(item.created_at).toLocaleDateString()} - ` : ` + ` + : ` {item.${primaryKey}.slice(0, 8)}... - `} + ` + }
diff --git a/apps/ui-library/components/tanstack-db-generator.tsx b/apps/ui-library/components/tanstack-db-generator.tsx deleted file mode 100644 index b68f7d986c6..00000000000 --- a/apps/ui-library/components/tanstack-db-generator.tsx +++ /dev/null @@ -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(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 = { - 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 ( -
- {!isGenerated ? ( - <> -
-
- - setProjectRef(e.target.value)} - className="font-mono" - autoComplete="off" - /> -

- Found in your project settings or URL (e.g., abcdefghijklmnop) -

-
-
- - setAnonKey(e.target.value)} - className="font-mono" - type="password" - autoComplete="off" - /> -

- Found in your project's API settings -

-
-
- - {validationError && ( -
- {validationError} -
- )} - - - - ) : ( - <> -
- Successfully connected to your Supabase project. 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/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..1eae498d05b --- /dev/null +++ b/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx @@ -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(null) + const [selectedProject, setSelectedProject] = useState(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 ( +
+ + {isApiKeysPending && selectedProject && ( +

Loading API keys...

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

Failed to load API keys for this project.

+ )} +
+ ) +} 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 index 9e5c546da1a..b5a836256ba 100644 --- a/apps/ui-library/content/docs/nextjs/tanstack-db.mdx +++ b/apps/ui-library/content/docs/nextjs/tanstack-db.mdx @@ -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 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..4015522019a --- /dev/null +++ b/apps/ui-library/lib/fetch/projectApi.ts @@ -0,0 +1,68 @@ +import { get } from './fetchWrappers' +import { queryOptions } from '@tanstack/react-query' + +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..dcefeb2effe --- /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) { + 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) + } +} 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/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 627d2620ec9..c116dde4ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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