mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 21:59:16 +03:00
Add project selector to the page.
This commit is contained in:
1 parent
9b67c67b67
commit
7aaeb796c7
20 files changed
+984
-232
No files matched your search
@@ -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'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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ResponseError {
|
||||
code?: number
|
||||
message: string
|
||||
requestId?: string
|
||||
}
|
||||
Generated
+7
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user