diff --git a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx
index 5519801208c..09c05f0e03e 100644
--- a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx
+++ b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx
@@ -16,7 +16,6 @@ import { FormActions } from 'components/ui/Forms/FormActions'
import Panel from 'components/ui/Panel'
import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation'
import type { Profile } from 'data/profile/types'
-import type { FormSchema } from 'types'
const FormSchema = z.object({
first_name: z.string().optional(),
diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx
index e0950b10feb..b021ef92ef7 100644
--- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx
+++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx
@@ -14,7 +14,6 @@ import { useDatabaseFunctionCreateMutation } from 'data/database-functions/datab
import { DatabaseFunction } from 'data/database-functions/database-functions-query'
import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
-import type { FormSchema } from 'types'
import {
Button,
FormControl_Shadcn_,
diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx
index 519a1cdbffe..c634075a8a9 100644
--- a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx
+++ b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx
@@ -276,7 +276,7 @@ const EmptyIntegrationConnection = React.forwardRef<
onClick: () => void
disabled?: boolean
}
->(({ className, showNode = true, onClick, disabled, ...props }, ref) => {
+>(({ className, showNode = true, onClick, disabled, orgSlug, ...props }, ref) => {
return (
{
return (
+ {IS_INTEGRATIONS_DIRECTORY_ENABLED &&
}
+ {IS_INTEGRATIONS_DIRECTORY_ENABLED &&
}
>
diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx
new file mode 100644
index 00000000000..a6580264d1f
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice.tsx
@@ -0,0 +1,33 @@
+import { AlertCircleIcon } from 'lucide-react'
+import Link from 'next/link'
+
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useAppStateSnapshot } from 'state/app-state'
+import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
+
+const IntegrationsDirectoryPlanNotice = () => {
+ const snap = useAppStateSnapshot()
+ const selectedOrg = useSelectedOrganization()
+
+ return (
+
+
+
+ Integrations directory is only available on the Pro plan and above
+
+
+ Go to your organization's billing settings and upgrade your plan to enable integrations
+ directory for this project
+
+
+
+
+ Upgrade to Pro
+
+
+
+
+ )
+}
+
+export default IntegrationsDirectoryPlanNotice
diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx
new file mode 100644
index 00000000000..265708f2302
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/CreateIntegrationSheet.tsx
@@ -0,0 +1,491 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { FileX, X } from 'lucide-react'
+import { SubmitHandler, useForm } from 'react-hook-form'
+import toast from 'react-hot-toast'
+import z from 'zod'
+
+import { Markdown } from 'components/interfaces/Markdown'
+import { useIntegrationDirectoryEntryCreateMutation } from 'data/integrations-directory/integration-directory-entry-create-mutation'
+import { useIntegrationDirectoryEntryDeleteMutation } from 'data/integrations-directory/integration-directory-entry-delete-mutation'
+import { useIntegrationDirectoryEntryUpdateMutation } from 'data/integrations-directory/integration-directory-entry-update-mutation'
+import { IntegrationEntry } from 'data/integrations-directory/integrations-directory-query'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useState } from 'react'
+import {
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Alert_Shadcn_,
+ Button,
+ ExpandingTextArea,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Form_Shadcn_,
+ Input_Shadcn_,
+ SelectContent_Shadcn_,
+ SelectItem_Shadcn_,
+ SelectTrigger_Shadcn_,
+ SelectValue_Shadcn_,
+ Select_Shadcn_,
+ Separator,
+ SheetClose,
+ SheetFooter,
+ SheetHeader,
+ SheetSection,
+ SheetTitle,
+ TabsContent_Shadcn_,
+ TabsList_Shadcn_,
+ TabsTrigger_Shadcn_,
+ Tabs_Shadcn_,
+ WarningIcon,
+ cn,
+} from 'ui'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { DroppableTextArea } from './DroppableTextArea'
+import { LogoFileInput } from './LogoFileInput'
+import content from 'pages/api/projects/[ref]/content'
+
+const FORM_ID = 'create-integration-sidepanel'
+
+interface CreateIntegrationSheetProps {
+ integrationEntry?: IntegrationEntry
+ onChange: (hasChanges: boolean) => void
+ onClosing: (ignoreChanges: boolean) => void
+}
+
+export const IntegrationCategory = {
+ api: 'API',
+ auth: 'Auth',
+ caching: 'Caching / Offline-First',
+ data: 'Data Platform',
+ devtools: 'DevTools',
+ fdw: 'Foreign Data Wrapper',
+ lowcode: 'Low-Code',
+ messaging: 'Messaging',
+ storage: 'Storage',
+} as const
+
+const FormSchema = z.object({
+ category: z.enum(
+ ['api', 'auth', 'caching', 'data', 'devtools', 'fdw', 'lowcode', 'messaging', 'storage'],
+ {
+ errorMap: () => {
+ return { message: 'Please select one of the provided categories' }
+ },
+ }
+ ),
+ description: z.string().trim().min(1, 'The description must be at least 1 character'),
+ developer: z.string().trim().min(1, 'The developer must be at least 1 character'),
+ docs: z.string().url('The docs must be a valid URL'),
+ logo: z.string().url('The logo is missing, please upload an image'),
+ overview: z.string().trim().min(1, 'The overview must be at least 1 character'),
+ slug: z
+ .string()
+ .regex(/^[A-Za-z0-9_-]+$/, 'The slug must be alphanumeric and can contain - or _'),
+ title: z.string().trim().min(1, 'The title must be at least 1 character'),
+ video: z.string().optional(),
+ website: z.string().url('The website must be a valid URL'),
+})
+
+export const CreateIntegrationSheet = ({
+ integrationEntry,
+ onChange,
+ onClosing,
+}: CreateIntegrationSheetProps) => {
+ const organization = useSelectedOrganization()
+ const [showDeleteDraftDialog, setShowDeleteDraftDialog] = useState(false)
+
+ const isDraft = !!integrationEntry?.parent_id || !!integrationEntry?.id
+
+ const form = useForm
>({
+ resolver: zodResolver(FormSchema),
+ defaultValues: {
+ category: integrationEntry?.category ?? undefined,
+ description: integrationEntry?.description ?? '',
+ developer: integrationEntry?.developer ?? '',
+ docs: integrationEntry?.docs ?? '',
+ logo: integrationEntry?.logo ?? '',
+ overview: integrationEntry?.overview ?? '',
+ slug: integrationEntry?.slug ?? '',
+ title: integrationEntry?.title ?? '',
+ video: integrationEntry?.video ?? '',
+ website: integrationEntry?.website ?? '',
+ },
+ })
+
+ const { mutate: createIntegrationEntry, isLoading: isCreating } =
+ useIntegrationDirectoryEntryCreateMutation()
+ const { mutate: updateIntegrationEntry, isLoading: isUpdating } =
+ useIntegrationDirectoryEntryUpdateMutation()
+ const { mutate: deleteIntegrationEntry, isLoading: isDeleting } =
+ useIntegrationDirectoryEntryDeleteMutation({
+ onSuccess: () => {
+ if (showDeleteDraftDialog) {
+ toast.success('Successfully discarded draft changes.')
+ }
+
+ setShowDeleteDraftDialog(false)
+ onClosing(true)
+ },
+ })
+
+ const onSubmit: SubmitHandler> = async (data) => {
+ if (isDraft) {
+ updateIntegrationEntry(
+ {
+ orgSlug: organization!.slug,
+ params: {
+ category: data.category,
+ description: data.description,
+ developer: data.developer,
+ docs: data.docs,
+ logo: data.logo,
+ overview: data.overview,
+ slug: data.slug,
+ title: data.title,
+ video: data.video,
+ website: data.website,
+ } as any,
+ },
+ {
+ onSuccess: () => {
+ toast.success(`Successfully updated integration`)
+ onClosing(true)
+ },
+ }
+ )
+ } else {
+ createIntegrationEntry(
+ {
+ orgSlug: organization!.slug,
+ params: {
+ category: data.category,
+ description: data.description,
+ developer: data.developer,
+ docs: data.docs,
+ logo: data.logo,
+ overview: data.overview,
+ slug: data.slug,
+ title: data.title,
+ video: data.video,
+ website: data.website,
+ } as any,
+ },
+ {
+ onSuccess: () => {
+ toast.success(`Successfully added an integration entry ${data.slug}`)
+ onClosing(true)
+ },
+ }
+ )
+ }
+ }
+
+ const isLoading = isCreating || isUpdating || isDeleting
+
+ // set a variable in the parent component to indicate if the form has changes. The variable is used to show a
+ // confirmation dialog when the user tries to close the sheet.
+ form.watch(() => {
+ onChange(form.formState.isDirty || !form.formState.isSubmitted)
+ })
+
+ return (
+ <>
+
+
+
+
+ Close
+
+
+ {isDraft ? `Update integration` : `Add integration`}
+
+
+
+
+
+
+
+
+ {!isCreating && integrationEntry?.id && (
+
+ {integrationEntry.approved === false && (
+ setShowDeleteDraftDialog(true)}
+ loading={isLoading}
+ >
+ Discard Draft
+
+ )}
+
+ )}
+
+ onClosing(false)} loading={isLoading}>
+ Cancel
+
+
+ {!integrationEntry?.id ? 'Create' : 'Update'}
+
+
+
+ setShowDeleteDraftDialog(false)}
+ onConfirm={() => {
+ deleteIntegrationEntry({ orgSlug: organization!.slug, entryId: integrationEntry?.id! })
+ }}
+ >
+
+ Are you sure you want to discard the draft changes? This action cannot be undone.
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx
new file mode 100644
index 00000000000..cbe67096fa8
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/DroppableTextArea.tsx
@@ -0,0 +1,176 @@
+import { useParams } from 'common'
+import { uuidv4 } from 'lib/helpers'
+import {
+ ChangeEventHandler,
+ Dispatch,
+ DragEventHandler,
+ SetStateAction,
+ useRef,
+ useState,
+} from 'react'
+import { cn, Textarea } from 'ui'
+import { uploadFileToSupabaseComProject } from './upload'
+import { TextArea } from '@ui/components/shadcn/ui/text-area'
+
+const VALID_FILE_EXTENSIONS = ['image/jpeg', 'image/png']
+
+interface DroppableTextAreaProps {
+ value: string
+ onChange: Dispatch>
+ className?: string
+ disabled?: boolean
+}
+
+const getFile = async (fileEntry: FileSystemFileEntry) => {
+ try {
+ return await new Promise((resolve, reject) => fileEntry.file(resolve, reject))
+ } catch (err) {
+ console.error('getFile error:', err)
+ return undefined
+ }
+}
+
+// https://stackoverflow.com/a/53058574
+const getFilesDataTransferItems = async (items: DataTransferItemList) => {
+ const files = []
+ const queue: FileSystemEntry[] = []
+ for (const item of items) {
+ const entry = item.webkitGetAsEntry()
+ if (entry) {
+ queue.push(entry)
+ }
+ }
+ while (queue.length > 0) {
+ const entry = queue.shift()
+ if (entry && entry.isFile) {
+ const file = await getFile(entry as FileSystemFileEntry)
+ if (file !== undefined) {
+ // file.path = entry.fullPath.slice(1)
+ files.push(file)
+ }
+ }
+ }
+ return files
+}
+
+export const DroppableTextArea = ({
+ value,
+ onChange,
+ disabled,
+ className,
+}: DroppableTextAreaProps) => {
+ const { slug } = useParams()
+ const fileInputRef = useRef(null)
+ const [areFilesValid, setAreFilesValid] = useState<'none' | 'valid' | 'invalid'>('none')
+
+ const onDragOver: DragEventHandler = (event) => {
+ if (event) {
+ event.stopPropagation()
+ event.preventDefault()
+ }
+ }
+
+ const uploadFiles = async (items: DataTransferItemList) => {
+ const files = await getFilesDataTransferItems(items)
+
+ files.forEach((file) => {
+ const generatedId = uuidv4()
+ onChange((v) => {
+ return v + `\n![Uploading ${file.name}…]()\n`
+ })
+
+ uploadFileToSupabaseComProject(
+ 'temp-for-testing-integrations',
+ `${slug}/${generatedId}`,
+ file
+ ).then((url) => {
+ onChange((v) => {
+ return v.replace(`![Uploading ${file.name}…]()`, ``)
+ })
+ })
+ })
+ }
+
+ const onDrop: DragEventHandler = async (event) => {
+ onDragOver(event)
+
+ if (areFilesValid === 'valid') {
+ const items = (event as any).target.files || (event as any).dataTransfer.items
+ uploadFiles(items)
+ }
+ }
+
+ const checkFiles: DragEventHandler = (event) => {
+ const items = event.dataTransfer.items || []
+ if (items.length === 0) {
+ setAreFilesValid('none')
+ }
+ for (const item of items) {
+ if (!VALID_FILE_EXTENSIONS.includes(item.type)) {
+ setAreFilesValid('invalid')
+ return
+ }
+ }
+ setAreFilesValid('valid')
+ }
+
+ const onUploadClick = () => {
+ if (disabled) return
+ fileInputRef.current?.click()
+ }
+
+ const onInputChange: ChangeEventHandler = async (event) => {
+ const items = fileInputRef.current?.files || []
+ if (items.length > 0) {
+ // Create a new DataTransfer object
+ const list = new DataTransfer()
+
+ // Add each file to the DataTransfer object
+ for (const file of items) {
+ list.items.add(file)
+ }
+ uploadFiles(list.items)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/LogoFileInput.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/LogoFileInput.tsx
new file mode 100644
index 00000000000..1c5c5b2f0e1
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/LogoFileInput.tsx
@@ -0,0 +1,101 @@
+import { useParams } from 'common'
+import uuidv4 from 'lib/uuid'
+import { Upload } from 'lucide-react'
+import { ChangeEvent, useRef } from 'react'
+import {
+ Button,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ IconEdit,
+} from 'ui'
+import { uploadFileToSupabaseComProject } from './upload'
+
+export const LogoFileInput = ({
+ logoUrl,
+ onChange,
+}: {
+ logoUrl: string | null
+ onChange: (s: string | null) => void
+}) => {
+ const { slug } = useParams()
+ const fileInputRef = useRef(null)
+
+ const onFileUpload = async (event: ChangeEvent) => {
+ event.persist()
+ const [file] = event.target.files || (event as any).dataTransfer.items
+
+ if (!file) return
+
+ const generatedId = uuidv4()
+ uploadFileToSupabaseComProject(
+ 'temp-for-testing-integrations',
+ `${slug}/logo/${generatedId}`,
+ file,
+ true
+ ).then((url) => {
+ onChange(url!)
+ })
+ }
+
+ return (
+ <>
+ {logoUrl ? (
+
+
+
+
+
+
+
+
+
+ {
+ fileInputRef.current?.click()
+ }}
+ >
+ Upload image
+
+ onChange(null)}>
+ Remove image
+
+
+
+
+
+ ) : (
+ fileInputRef.current?.click()}
+ >
+
+
Upload logo
+
+ )}
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/SupabaseSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/SupabaseSection.tsx
new file mode 100644
index 00000000000..b8fcf98bdf9
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/SupabaseSection.tsx
@@ -0,0 +1,259 @@
+import { useState } from 'react'
+
+import IntegrationsDirectoryPlanNotice from 'components/interfaces/Organization/IntegrationSettings/IntegrationsDirectoryPlanNotice'
+import {
+ ScaffoldContainer,
+ ScaffoldSection,
+ ScaffoldSectionContent,
+ ScaffoldSectionDetail,
+} from 'components/layouts/Scaffold'
+import { useIntegrationDirectoryEntryDeleteMutation } from 'data/integrations-directory/integration-directory-entry-delete-mutation'
+import { useIntegrationsDirectoryQuery } from 'data/integrations-directory/integrations-directory-query'
+import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import toast from 'react-hot-toast'
+import { Button, Sheet, SheetContent } from 'ui'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+import { CreateIntegrationSheet } from './CreateIntegrationSheet'
+
+const SupabaseSection = () => {
+ const organization = useSelectedOrganization()
+ const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
+ const isFreePlan = subscription?.plan.id === 'free'
+ const [showDeleteEntryDialog, setShowDeleteEntryDialog] = useState(false)
+
+ const { data, isLoading, isSuccess, isError } = useIntegrationsDirectoryQuery(
+ { orgSlug: organization?.slug! },
+ { enabled: !!organization?.slug }
+ )
+ const { mutate: deleteIntegrationEntry, isLoading: isDeleting } =
+ useIntegrationDirectoryEntryDeleteMutation({
+ onSuccess: () => {
+ toast.success('Successfully deleted integration entry.')
+
+ setShowDeleteEntryDialog(false)
+ setVisible(false)
+ },
+ })
+
+ const entries = data || []
+
+ const entry = entries.find((entry) => entry.approved)
+ const draftEntry = entries.find((entry) => !entry.approved)
+
+ // whether the create integration sheet is shown
+ const [visible, setVisible] = useState(false)
+ // describes whether the create integration sheet form has changes (is dirty)
+ const [hasChanges, setHasChanges] = useState(false)
+ // if the create integration sheet has changes that haven't been saved, show a confirmation modal
+ const [isClosingPanel, setIsClosingPanel] = useState(false)
+
+ //
+ /**
+ * Closes the create integration sheet. Will show a confirmation modal if there are unsaved changes.
+ *
+ * @param ignoreChanges ignore the changes in some cases (when closing the sheet after a successful save)
+ */
+ const closeSheet = (ignoreChanges: boolean = false) => {
+ if (hasChanges && !ignoreChanges) {
+ setIsClosingPanel(true)
+ } else {
+ setIsClosingPanel(false)
+ setVisible(false)
+ setHasChanges(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Add your own integrations to{' '}
+
+ our Integrations Directory
+
+
+
+ {isFreePlan && (
+
+
+
+ )}
+ {!isFreePlan && (
+
+ {isLoading && Loading...
}
+
+ {isSuccess && (
+ <>
+ {/*
+ The logic here handles the following cases:
+ - if there's no entry, Submit draft application for review
+ - if there's no entry and a draft, Edit draft application
+ - if there's an entry and no draft, Submit draft application for review
+ - if there's an entry and a draft, Edit draft application
+ */}
+ {!entry && !draftEntry && (
+
+
+ If you want your integration to be listed in{' '}
+
+ our Integrations Directory
+
+ , please submit an application. We will review it and get back to you.
+
+
setVisible(true)}>
+ Submit an application
+
+
+ )}
+ {!entry && draftEntry && (
+
+
+ Your entry awaits approval by Supabase team. In the meantime, you can see the
+ preview{' '}
+
+ here
+
+ . Once you're finished with the draft and want to submit the draft for review,
+ please contact us via support.
+
+
setVisible(true)}>
+ Edit draft for review
+
+
+ )}
+ {entry && !draftEntry && (
+
+ )}
+ {entry && draftEntry && (
+ <>
+
+
+
+ Your entry updates await approval by Supabase team. In the meantime, you can
+ see the preview{' '}
+
+ here
+
+ . Once you're finished with the draft and want to submit the draft for
+ review, please contact us via support.
+
+
setVisible(true)}>
+ Edit draft for review
+
+
+ >
+ )}
+
+ setShowDeleteEntryDialog(false)}
+ onConfirm={() => {
+ if (entry) {
+ deleteIntegrationEntry({
+ orgSlug: organization!.slug,
+ entryId: entry?.id,
+ })
+ }
+ }}
+ >
+
+ Are you sure you want to delete the integration entry? This action cannot be
+ undone.
+
+
+ setIsClosingPanel(false)}
+ onConfirm={() => {
+ setIsClosingPanel(false)
+ setVisible(false)
+ setHasChanges(false)
+ }}
+ >
+
+ There are unsaved changes. Are you sure you want to close the panel? Your
+ changes will be lost.
+
+
+
+
+
+ {
+ if (value !== hasChanges) {
+ setHasChanges(value)
+ }
+ }}
+ onClosing={closeSheet}
+ integrationEntry={draftEntry || entry}
+ />
+
+
+ >
+ )}
+
+ )}
+
+
+ )
+}
+
+export default SupabaseSection
diff --git a/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/upload.ts b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/upload.ts
new file mode 100644
index 00000000000..7793c3934ee
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/SupabaseIntegration/upload.ts
@@ -0,0 +1,45 @@
+import { createClient } from '@supabase/supabase-js'
+
+const SUPABASE_COM_URL = process.env.NEXT_PUBLIC_SUPABASE_COM_URL || ''
+const SUPABASE_COM_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_COM_ANON_KEY || ''
+
+// Used to upload images to the supabase-com project. The images will be used for integrations pages.
+export const uploadFileToSupabaseComProject = async (
+ bucket: string,
+ fileName: string,
+ image: File,
+ getUrl: boolean = true
+) => {
+ const supabaseClient = createClient(SUPABASE_COM_URL, SUPABASE_COM_ANON_KEY, {
+ auth: {
+ persistSession: false,
+ autoRefreshToken: false,
+ // @ts-ignore
+ multiTab: false,
+ detectSessionInUrl: false,
+ localStorage: {
+ getItem: (key: string) => undefined,
+ setItem: (key: string, value: string) => {},
+ removeItem: (key: string) => {},
+ },
+ },
+ })
+
+ const options = { cacheControl: '3600' }
+
+ const { data: file, error } = await supabaseClient.storage
+ .from(bucket)
+ .upload(fileName, image, options)
+
+ if (error) {
+ console.error('Failed to upload:', error)
+ return undefined
+ }
+
+ if (file && getUrl) {
+ const { data } = await supabaseClient.storage.from(bucket).getPublicUrl(file.path)
+ return data?.publicUrl
+ }
+
+ return undefined
+}
diff --git a/apps/studio/data/integrations-directory/integration-directory-entry-create-mutation.ts b/apps/studio/data/integrations-directory/integration-directory-entry-create-mutation.ts
new file mode 100644
index 00000000000..ff6870be0d4
--- /dev/null
+++ b/apps/studio/data/integrations-directory/integration-directory-entry-create-mutation.ts
@@ -0,0 +1,66 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'react-hot-toast'
+
+import { handleError, post } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { integrationsDirectoryKeys } from './keys'
+import type { components } from 'api-types'
+
+type IntegrationDirectoryEntryCreateVariables = {
+ orgSlug: string
+ params: components['schemas']['OrgIntegrationsDirectoryRequestBody']
+}
+
+export async function createIntegrationDirectoryEntry({
+ orgSlug,
+ params,
+}: IntegrationDirectoryEntryCreateVariables) {
+ const { data, error } = await post('/platform/organizations/{slug}/integrations-directory', {
+ params: { path: { slug: orgSlug } },
+ body: params,
+ })
+
+ if (error) handleError(error)
+ return data
+}
+
+export type IntegrationDirectoryEntryCreateData = Awaited<
+ ReturnType
+>
+
+export const useIntegrationDirectoryEntryCreateMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions<
+ IntegrationDirectoryEntryCreateData,
+ ResponseError,
+ IntegrationDirectoryEntryCreateVariables
+ >,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation<
+ IntegrationDirectoryEntryCreateData,
+ ResponseError,
+ IntegrationDirectoryEntryCreateVariables
+ >((vars) => createIntegrationDirectoryEntry(vars), {
+ async onSuccess(data, variables, context) {
+ await Promise.all([
+ queryClient.invalidateQueries(
+ integrationsDirectoryKeys.integrationsDirectoryList(variables.orgSlug)
+ ),
+ ])
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to add an entry to Integrations Directory: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ })
+}
diff --git a/apps/studio/data/integrations-directory/integration-directory-entry-delete-mutation.ts b/apps/studio/data/integrations-directory/integration-directory-entry-delete-mutation.ts
new file mode 100644
index 00000000000..6e41799ecd9
--- /dev/null
+++ b/apps/studio/data/integrations-directory/integration-directory-entry-delete-mutation.ts
@@ -0,0 +1,70 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'react-hot-toast'
+
+import { del, handleError } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { integrationsDirectoryKeys } from './keys'
+
+type IntegrationDirectoryEntryDeleteVariables = {
+ orgSlug: string
+ entryId: number
+}
+
+export async function deleteIntegrationDirectoryEntry({
+ entryId,
+}: IntegrationDirectoryEntryDeleteVariables) {
+ const { data, error } = await del(
+ '/platform/organizations/{slug}/integrations-directory/{entry_id}',
+ {
+ params: {
+ path: {
+ entry_id: String(entryId),
+ },
+ },
+ }
+ )
+
+ if (error) handleError(error)
+ return data
+}
+
+export type IntegrationDirectoryEntryDeleteData = Awaited<
+ ReturnType
+>
+
+export const useIntegrationDirectoryEntryDeleteMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions<
+ IntegrationDirectoryEntryDeleteData,
+ ResponseError,
+ IntegrationDirectoryEntryDeleteVariables
+ >,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation<
+ IntegrationDirectoryEntryDeleteData,
+ ResponseError,
+ IntegrationDirectoryEntryDeleteVariables
+ >((vars) => deleteIntegrationDirectoryEntry(vars), {
+ async onSuccess(data, variables, context) {
+ await Promise.all([
+ queryClient.invalidateQueries(
+ integrationsDirectoryKeys.integrationsDirectoryList(variables.orgSlug)
+ ),
+ ])
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to delete an entry to Integrations Directory: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ })
+}
diff --git a/apps/studio/data/integrations-directory/integration-directory-entry-update-mutation.ts b/apps/studio/data/integrations-directory/integration-directory-entry-update-mutation.ts
new file mode 100644
index 00000000000..59b9d63def1
--- /dev/null
+++ b/apps/studio/data/integrations-directory/integration-directory-entry-update-mutation.ts
@@ -0,0 +1,66 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'react-hot-toast'
+
+import { handleError, put } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { integrationsDirectoryKeys } from './keys'
+import type { components } from 'api-types'
+
+type IntegrationDirectoryEntryUpdateVariables = {
+ orgSlug: string
+ params: components['schemas']['OrgIntegrationsDirectoryRequestBody']
+}
+
+export async function updateIntegrationDirectoryEntry({
+ orgSlug,
+ params,
+}: IntegrationDirectoryEntryUpdateVariables) {
+ const { data, error } = await put('/platform/organizations/{slug}/integrations-directory', {
+ params: { path: { slug: orgSlug } },
+ body: params,
+ })
+
+ if (error) handleError(error)
+ return data
+}
+
+export type IntegrationDirectoryEntryUpdateData = Awaited<
+ ReturnType
+>
+
+export const useIntegrationDirectoryEntryUpdateMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions<
+ IntegrationDirectoryEntryUpdateData,
+ ResponseError,
+ IntegrationDirectoryEntryUpdateVariables
+ >,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation<
+ IntegrationDirectoryEntryUpdateData,
+ ResponseError,
+ IntegrationDirectoryEntryUpdateVariables
+ >((vars) => updateIntegrationDirectoryEntry(vars), {
+ async onSuccess(data, variables, context) {
+ await Promise.all([
+ queryClient.invalidateQueries(
+ integrationsDirectoryKeys.integrationsDirectoryList(variables.orgSlug)
+ ),
+ ])
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to add an entry to Integrations Directory: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ })
+}
diff --git a/apps/studio/data/integrations-directory/integrations-directory-query.ts b/apps/studio/data/integrations-directory/integrations-directory-query.ts
new file mode 100644
index 00000000000..d2f2b3746dd
--- /dev/null
+++ b/apps/studio/data/integrations-directory/integrations-directory-query.ts
@@ -0,0 +1,45 @@
+import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+
+import { components } from 'api-types'
+import { get } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { integrationsDirectoryKeys } from './keys'
+
+type IntegrationsDirectoryVariables = {
+ orgSlug: string
+}
+
+export type IntegrationEntry = components['schemas']['IntegrationsDirectoryEntry']
+
+export async function getIntegrationsDirectory(
+ { orgSlug }: IntegrationsDirectoryVariables,
+ signal?: AbortSignal
+) {
+ if (!orgSlug) {
+ return
+ }
+
+ const { data, error } = await get('/platform/organizations/{slug}/integrations-directory', {
+ params: { path: { slug: orgSlug } },
+ signal,
+ })
+
+ // no handleError here because the API returns 404 error instead of empty array.
+ return error ? null : data
+}
+
+export type IntegrationsDirectoryData = Awaited>
+export type IntegrationsError = ResponseError
+
+export const useIntegrationsDirectoryQuery = (
+ vars: IntegrationsDirectoryVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ integrationsDirectoryKeys.integrationsDirectoryList(vars.orgSlug),
+ ({ signal }) => getIntegrationsDirectory(vars, signal),
+ { enabled: enabled, ...options }
+ )
diff --git a/apps/studio/data/integrations-directory/keys.ts b/apps/studio/data/integrations-directory/keys.ts
new file mode 100644
index 00000000000..b424cb61f5b
--- /dev/null
+++ b/apps/studio/data/integrations-directory/keys.ts
@@ -0,0 +1,3 @@
+export const integrationsDirectoryKeys = {
+ integrationsDirectoryList: (orgSlug: string) => ['integrations-directory', orgSlug] as const,
+}
diff --git a/apps/www/components/Partners/BecomeAPartner.tsx b/apps/www/components/Partners/BecomeAPartner.tsx
index 7dca93382c3..004ca7af3be 100644
--- a/apps/www/components/Partners/BecomeAPartner.tsx
+++ b/apps/www/components/Partners/BecomeAPartner.tsx
@@ -5,10 +5,13 @@ export default function BecomeAPartner() {
return (
-
Ready to work together?
+ Want to share with the community?
-
- Become a partner
+
+ List your integration
diff --git a/apps/www/components/Partners/TileGrid.tsx b/apps/www/components/Partners/TileGrid.tsx
index 1954d492ceb..3dd156006f1 100644
--- a/apps/www/components/Partners/TileGrid.tsx
+++ b/apps/www/components/Partners/TileGrid.tsx
@@ -1,7 +1,28 @@
+import dayjs from 'dayjs'
import Image from 'next/image'
import Link from 'next/link'
+import { IntegrationCategory } from '~/pages/partners/integrations'
import type { Partner } from '~/types/partners'
+const NEW_ENTRIES_THRESHOLD = 30 // days
+
+// TODO(Kamil): Use `IntegrationsDirectoryEntry` type instead of `Partner` everywhere
+// once we migrate "Experts" away from the website.
+function renderNewPill(partner: Partner) {
+ // We need this guard to not break existing Experts tiles during migration.
+ if (!('approved_at' in partner) || typeof partner.approved_at !== 'string') {
+ return null
+ }
+ if (dayjs().subtract(NEW_ENTRIES_THRESHOLD, 'days').isBefore(dayjs(partner.approved_at))) {
+ return (
+
+ New
+
+ )
+ }
+ return null
+}
+
export default function TileGrid({
partners,
hideCategories = false,
@@ -49,6 +70,7 @@ export default function TileGrid({
+ {renderNewPill(p)}
{p.title}
(
- {!hideCategories &&
{category} }
+ {!hideCategories && (
+
+ {IntegrationCategory[category as keyof typeof IntegrationCategory]}
+
+ )}
{partnersByCategory[category].map((p) => (
@@ -92,6 +118,7 @@ export default function TileGrid({
+ {renderNewPill(p)}
{p.title}
}
-// This function gets called at build time
-export const getStaticPaths: GetStaticPaths = async () => {
- const { data: slugs } = await supabase.from('partners').select('slug')
+export const getServerSideProps: GetServerSideProps = async ({ params, query }) => {
+ // Try new integrations directory first, and bail to the old solution if entry is not present.
+ const url = `${API_URL}/integrations-directory/${params!.slug}${query.preview_token ? `?preview_token=${query.preview_token}` : ''}`
+ const response = await fetch(url)
+ const entry = (await response.json()) as IntegrationsDirectoryEntry
- const paths: {
- params: { slug: string }
- locale?: string | undefined
- }[] =
- slugs?.map(({ slug }) => ({
- params: {
- slug,
+ if (entry && response.status === 200) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: `/partners/integrations/${entry.slug}${query.preview_token ? `?preview_token=${query.preview_token}` : ''}`,
},
- })) ?? []
-
- return {
- paths,
- fallback: 'blocking',
+ }
}
-}
-// This also gets called at build time
-export const getStaticProps: GetStaticProps = async ({ params }) => {
let { data: partner } = await supabase
.from('partners')
.select('*')
diff --git a/apps/www/pages/partners/integrations/[slug].tsx b/apps/www/pages/partners/integrations/[slug].tsx
index 0f3b84cad0a..f9bd052f2f2 100644
--- a/apps/www/pages/partners/integrations/[slug].tsx
+++ b/apps/www/pages/partners/integrations/[slug].tsx
@@ -1,16 +1,16 @@
import { type CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx'
import { CH } from '@code-hike/mdx/components'
import { ChevronLeft, ExternalLink } from 'lucide-react'
-import { type GetStaticPaths, type GetStaticProps } from 'next'
-import Image from 'next/image'
-import Link from 'next/link'
+import { GetServerSideProps } from 'next'
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import { NextSeo } from 'next-seo'
+import Image from 'next/image'
+import Link from 'next/link'
import { type Dispatch, type SetStateAction, useState } from 'react'
import remarkGfm from 'remark-gfm'
-import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
+import { Swiper, SwiperSlide } from 'swiper/react'
import { useBreakpoint } from 'common'
import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
@@ -20,8 +20,10 @@ import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import ImageModal from '~/components/ImageModal'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
+import { API_URL } from '~/lib/constants'
import supabase from '~/lib/supabaseMisc'
import type { Partner } from '~/types/partners'
+import { IntegrationsDirectoryEntry } from '.'
import Error404 from '../../404'
/**
@@ -47,9 +49,13 @@ function mdxComponents(callback: Dispatch
>) {
function Partner({
partner,
overview,
+ isPreview,
}: {
- partner: Partner
+ partner: IntegrationsDirectoryEntry & {
+ images?: string[] // TODO: Remove once all data is migrated from Misc to Integrations Directory, as we dont store images anymore.
+ }
overview: MDXRemoteSerializeResult, Record>
+ isPreview: boolean
}) {
const [focusedImage, setFocusedImage] = useState(null)
const isNarrow = useBreakpoint('lg')
@@ -64,7 +70,7 @@ function Partner({
openGraph={{
title: `${partner.title} | Works With Supabase`,
description: partner.description,
- url: `https://supabase.com/partners/integrations/${partner.slug}`,
+ url: `${process.env.NEXT_PUBLIC_URL}/partners/integrations/${partner.slug}`,
images: [
{
url: partner.images ? partner.images[0] : partner.logo,
@@ -179,6 +185,14 @@ function Partner({
{isNarrow && }
+ {isPreview && (
+
+
+ This is a preview page managed by the partner and has not been approved by
+ Supabase team.
+
+
+ )}
}
- {partner.call_to_action_link && (
+ {/* TODO(Kamil): This has changed to be a mandatory field. Remove this guard once we move all the data. */}
+ {partner.docs && (
@@ -214,7 +229,7 @@ function Partner({
)
}
-const PartnerDetails = ({ partner }: { partner: Partner }) => {
+const PartnerDetails = ({ partner }: { partner: IntegrationsDirectoryEntry }) => {
const videoThumbnail = partner.video
? `http://img.youtube.com/vi/${partner.video}/0.jpg`
: undefined
@@ -238,7 +253,8 @@ const PartnerDetails = ({ partner }: { partner: Partner }) => {
)}
- {partner.type === 'technology' && (
+ {/* TODO(Kamil): This has changed to be a mandatory field. Remove this guard once we move all the data. */}
+ {partner.developer && (
Developer
{partner.developer}
@@ -267,7 +283,7 @@ const PartnerDetails = ({ partner }: { partner: Partner }) => {
- {partner.type === 'technology' && partner.docs && (
+ {partner.docs && (
Documentation
{
)
}
-// This function gets called at build time
-export const getStaticPaths: GetStaticPaths = async () => {
- const { data: slugs } = await supabase
- .from('partners')
- .select('slug')
- .eq('approved', true)
- .eq('type', 'technology')
+export const getServerSideProps: GetServerSideProps = async ({ params, query }) => {
+ let result: IntegrationsDirectoryEntry | null = null
- const paths: {
- params: { slug: string }
- locale?: string | undefined
- }[] =
- slugs?.map(({ slug }) => ({
- params: {
- slug,
- },
- })) ?? []
+ const url = `${API_URL}/integrations-directory/${params!.slug}${query.preview_token ? `?preview_token=${query.preview_token}` : ''}`
+ const response = await fetch(url)
+ const entry = (await response.json()) as IntegrationsDirectoryEntry
- return {
- paths,
- fallback: 'blocking',
+ if (!entry || response.status !== 200) {
+ let { data: partner } = await supabase
+ .from('partners')
+ .select('*')
+ .eq('approved', true)
+ .eq('slug', params!.slug as string)
+ .single()
+
+ result = partner as unknown as IntegrationsDirectoryEntry
+ } else {
+ result = entry
}
-}
-// This also gets called at build time
-export const getStaticProps: GetStaticProps = async ({ params }) => {
- let { data: partner } = await supabase
- .from('partners')
- .select('*')
- .eq('approved', true)
- .eq('slug', params!.slug as string)
- .single()
-
- if (!partner) {
+ if (!result) {
return {
notFound: true,
}
}
+ // Once entry is approved, there is no point in keeping preview token in the url.
+ if (result.approved && query.preview_token) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: `${process.env.NEXT_PUBLIC_URL}/partners/integrations/${params!.slug}`,
+ },
+ }
+ }
+
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
@@ -340,7 +353,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
}
// Parse markdown
- const overview = await serialize(partner.overview, {
+ const overview = await serialize(result.overview, {
mdxOptions: {
useDynamicImport: true,
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
@@ -348,8 +361,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
})
return {
- props: { partner, overview },
- revalidate: 1800, // 30 minutes
+ props: { partner: result, overview, isPreview: !!query.preview_token },
}
}
diff --git a/apps/www/pages/partners/integrations/index.tsx b/apps/www/pages/partners/integrations/index.tsx
index adfb1bef6f6..4c23cdc908c 100644
--- a/apps/www/pages/partners/integrations/index.tsx
+++ b/apps/www/pages/partners/integrations/index.tsx
@@ -1,17 +1,39 @@
+import { components } from 'api-types'
import { NextSeo } from 'next-seo'
-import { useRouter } from 'next/router'
-import { useEffect, useState } from 'react'
+import router, { useRouter } from 'next/router'
+import { useCallback, useEffect, useState } from 'react'
import { IconLoader, IconSearch, Input } from 'ui'
import { useDebounce } from 'use-debounce'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import BecomeAPartner from '~/components/Partners/BecomeAPartner'
import PartnerLinkBox from '~/components/Partners/PartnerLinkBox'
+import { API_URL } from '~/lib/constants'
import supabase from '~/lib/supabaseMisc'
-import type { Partner } from '~/types/partners'
+import { Partner } from '~/types/partners'
import TileGrid from '../../../components/Partners/TileGrid'
+export type IntegrationsDirectoryEntry = components['schemas']['IntegrationsDirectoryEntry']
+
+export const IntegrationCategory = {
+ api: 'API',
+ auth: 'Auth',
+ caching: 'Caching / Offline-First',
+ data: 'Data Platform',
+ devtools: 'DevTools',
+ fdw: 'Foreign Data Wrapper',
+ lowcode: 'Low-Code',
+ messaging: 'Messaging',
+ storage: 'Storage',
+} as const
+
+type IntegrationCategoryCode = keyof typeof IntegrationCategory
+
export async function getStaticProps() {
+ const response = await fetch(`${API_URL}/integrations-directory`)
+ const value = (await response.json()) as components['schemas']['GetIntegrationsDirectoryResponse']
+ const entries = value.entries || []
+
const { data: partners } = await supabase
.from('partners')
.select('*')
@@ -20,12 +42,32 @@ export async function getStaticProps() {
.order('category')
.order('title')
+ // Merge the two lists of partners. Integration directory data takes precedence.
+ partners?.forEach((partner) => {
+ const found = entries.find((entry) => entry.title === partner.title)
+ if (!found) {
+ const category = Object.entries(IntegrationCategory).find(([k, v]) => v === partner.category)
+ const keyCategory = (category ? category[0] : 'api') as IntegrationCategoryCode
+
+ const toInsert = {
+ ...partner,
+ approved: true,
+ category: keyCategory,
+ docs: partner.docs ?? '',
+ featured: partner.featured ?? false,
+ images: partner.images ?? [],
+ approved_at: partner.created_at,
+ }
+
+ entries.push(toInsert)
+ }
+ })
+
return {
props: {
- partners,
+ partners: entries,
},
- // TODO: consider using Next.js' On-demand Revalidation with Supabase Database Webhooks instead
- revalidate: 1800, // 30 minutes
+ revalidate: false,
}
}
@@ -37,7 +79,9 @@ function IntegrationPartnersPage(props: Props) {
const initialPartners = props.partners ?? []
const [partners, setPartners] = useState(initialPartners)
- const allCategories = Array.from(new Set(initialPartners?.map((p) => p.category)))
+ const allCategories = Array.from(
+ new Set(initialPartners?.map((p) => p.category))
+ ) as IntegrationCategoryCode[]
const router = useRouter()
@@ -48,43 +92,43 @@ function IntegrationPartnersPage(props: Props) {
const [debouncedSearchTerm] = useDebounce(search, 300)
const [isSearching, setIsSearching] = useState(false)
+ const searchPartners = useCallback(
+ (term: string) => {
+ // This code is leftover from the original implementation when the search was done on the server side. We now
+ // search on client side because the data is coming from two different sources.
+ // let query = supabase.from('partners').select('*').eq('approved', true).order('category').order('title')
+
+ // if (term.trim()) {
+ // query = query.textSearch('tsv', `${term.trim()}`, {
+ // type: 'websearch',
+ // config: 'english',
+ // })
+ // }
+
+ return initialPartners.filter((partner) =>
+ partner.title.toLowerCase().includes(term.toLowerCase())
+ )
+ },
+ [initialPartners]
+ )
+
useEffect(() => {
- const searchPartners = async () => {
- setIsSearching(true)
-
- let query = supabase
- .from('partners')
- .select('*')
- .eq('approved', true)
- .order('category')
- .order('title')
-
- if (search.trim()) {
- query = query.textSearch('tsv', `${search.trim()}`, {
- type: 'websearch',
- config: 'english',
- })
- }
-
- const { data: partners } = await query
-
- return partners
- }
-
- if (search.trim() === '') {
+ if (debouncedSearchTerm.trim() === '') {
setIsSearching(false)
setPartners(initialPartners)
return
}
- searchPartners().then((partners) => {
- if (partners) {
- setPartners(partners)
- }
+ setIsSearching(true)
- setIsSearching(false)
- })
- }, [debouncedSearchTerm, router])
+ const partners = searchPartners(debouncedSearchTerm)
+
+ if (partners) {
+ setPartners(partners)
+ }
+
+ setIsSearching(false)
+ }, [debouncedSearchTerm, initialPartners, router, searchPartners])
return (
<>
@@ -139,7 +183,7 @@ function IntegrationPartnersPage(props: Props) {
onClick={() => router.push(`#${category.toLowerCase()}`)}
className="text-foreground-light block text-base"
>
- {category}
+ {IntegrationCategory[category]}
))}
@@ -171,10 +215,10 @@ function IntegrationPartnersPage(props: Props) {
/>
supabase_project_ref: string
}
+ /** @enum {string} */
+ IntegrationsDirectoryCategory:
+ | 'api'
+ | 'auth'
+ | 'caching'
+ | 'data'
+ | 'devtools'
+ | 'fdw'
+ | 'lowcode'
+ | 'messaging'
+ | 'storage'
+ IntegrationsDirectoryEntry: {
+ approved: boolean
+ category: components['schemas']['IntegrationsDirectoryCategory']
+ description: string
+ developer: string
+ docs: string
+ featured: boolean
+ id: number
+ inserted_at: string
+ logo: string
+ organization_slug: string | null
+ overview: string
+ parent_id: number | null
+ preview_token: string
+ slug: string
+ title: string
+ video: string | null
+ website: string
+ }
+ IntegrationsDirectoryListItem: {
+ approved_at: string | null
+ category: components['schemas']['IntegrationsDirectoryCategory']
+ description: string
+ featured: boolean
+ logo: string
+ slug: string
+ title: string
+ }
IntegrationVercelProject: {
framework?: string | null
id: string
@@ -4017,6 +4093,18 @@ export interface components {
OrgDocumentUrlResponse: {
fileUrl: string
}
+ OrgIntegrationsDirectoryRequestBody: {
+ category: components['schemas']['IntegrationsDirectoryCategory']
+ description: string
+ developer: string
+ docs: string
+ logo: string
+ overview: string
+ slug: string
+ title: string
+ video?: string
+ website: string
+ }
OrgMetricUsage: {
available_in_plan: boolean
capped: boolean
@@ -7298,6 +7386,42 @@ export interface operations {
}
}
}
+ /** Retrieves an integrations directory list */
+ IntegrationsDirectoryController_getIntegrationsDirectory: {
+ responses: {
+ 200: {
+ content: {
+ 'application/json': components['schemas']['GetIntegrationsDirectoryResponse']
+ }
+ }
+ /** @description Failed to retrieve integrations directory list */
+ 500: {
+ content: never
+ }
+ }
+ }
+ /** Retrieves integrations directory entry by slug */
+ IntegrationsDirectoryController_getIntegrationsDirectoryEntry: {
+ parameters: {
+ query: {
+ preview_token: string
+ }
+ path: {
+ slug: string
+ }
+ }
+ responses: {
+ 200: {
+ content: {
+ 'application/json': components['schemas']['GetIntegrationsDirectoryEntryResponse']
+ }
+ }
+ /** @description Failed to retrieve integrations directory entry */
+ 500: {
+ content: never
+ }
+ }
+ }
/** Gets integration with the given organization slug */
IntegrationsController_getUserInstallationForOrg: {
parameters: {
@@ -8223,6 +8347,102 @@ export interface operations {
}
}
}
+ /** Retrieves integrations directory entry for a given organization slug */
+ OrgIntegrationsDirectoryController_getIntegrationsDirectoryEntry: {
+ parameters: {
+ path: {
+ /** @description Organization slug */
+ slug: string
+ }
+ }
+ responses: {
+ 200: {
+ content: {
+ 'application/json': components['schemas']['IntegrationsDirectoryEntry'][]
+ }
+ }
+ 403: {
+ content: never
+ }
+ /** @description Failed to retrieve integrations directory entry */
+ 500: {
+ content: never
+ }
+ }
+ }
+ /** Updates integrations directory entry by creating a new pending draft */
+ OrgIntegrationsDirectoryController_updateIntegrationsDirectoryEntry: {
+ parameters: {
+ path: {
+ /** @description Organization slug */
+ slug: string
+ }
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['OrgIntegrationsDirectoryRequestBody']
+ }
+ }
+ responses: {
+ 200: {
+ content: {
+ 'application/json': components['schemas']['IntegrationsDirectoryEntry']
+ }
+ }
+ 403: {
+ content: never
+ }
+ /** @description Failed to update integrations directory entry */
+ 500: {
+ content: never
+ }
+ }
+ }
+ /** Creates integrations directory entry */
+ OrgIntegrationsDirectoryController_createIntegrationsDirectoryEntry: {
+ parameters: {
+ path: {
+ /** @description Organization slug */
+ slug: string
+ }
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['OrgIntegrationsDirectoryRequestBody']
+ }
+ }
+ responses: {
+ 201: {
+ content: {
+ 'application/json': components['schemas']['IntegrationsDirectoryEntry']
+ }
+ }
+ 403: {
+ content: never
+ }
+ /** @description Failed to create integrations directory entry */
+ 500: {
+ content: never
+ }
+ }
+ }
+ /** Deletes integrations directory entry or its pending draft */
+ OrgIntegrationsDirectoryController_deleteIntegrationsDirectoryEntry: {
+ parameters: {
+ path: {
+ entry_id: string
+ }
+ }
+ responses: {
+ 204: {
+ content: never
+ }
+ /** @description Failed to delete integrations directory entry */
+ 500: {
+ content: never
+ }
+ }
+ }
/** Gets organization's members */
MembersController_getMembers: {
parameters: {
diff --git a/packages/ui/src/components/shadcn/ui/text-area.tsx b/packages/ui/src/components/shadcn/ui/text-area.tsx
index 3f24b347fea..cf4711fd39f 100644
--- a/packages/ui/src/components/shadcn/ui/text-area.tsx
+++ b/packages/ui/src/components/shadcn/ui/text-area.tsx
@@ -4,16 +4,14 @@ import { cn } from '../../../lib/utils/cn'
export interface TextAreaProps extends React.TextareaHTMLAttributes {}
-const customClasses = ['bg-control']
-
const TextArea = React.forwardRef(
({ className, ...props }, ref) => {
return (