feat: Integrations Directory

This commit is contained in:
Ivan Vasilov authored and Kamil Ogórek committed 2024-08-26 19:00:25 +02:00
1 parent 1d65841542
commit e2b39647f0
23 files changed
+1776 -118

No files matched your search

@@ -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(),
@@ -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_,
@@ -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 (
<div
ref={ref}
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels'
import { Markdown } from 'components/interfaces/Markdown'
import SupabaseSection from 'components/interfaces/Settings/Integrations/SupabaseIntegration/SupabaseSection'
import VercelSection from 'components/interfaces/Settings/Integrations/VercelIntegration/VercelSection'
import {
ScaffoldContainer,
@@ -30,6 +31,9 @@ import { IntegrationConnectionItem } from '../../Integrations/IntegrationConnect
import SidePanelGitHubRepoLinker from './SidePanelGitHubRepoLinker'
import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker'
// Enable local and staging testing only for now.
const IS_INTEGRATIONS_DIRECTORY_ENABLED = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => {
return (
<img
@@ -191,6 +195,8 @@ The GitHub app will watch for changes in your repository such as file changes, b
<GitHubSection />
<ScaffoldDivider />
<VercelSection isProjectScoped={false} />
{IS_INTEGRATIONS_DIRECTORY_ENABLED && <ScaffoldDivider />}
{IS_INTEGRATIONS_DIRECTORY_ENABLED && <SupabaseSection />}
<SidePanelVercelProjectLinker />
<SidePanelGitHubRepoLinker />
</>
@@ -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 (
<Alert_Shadcn_ className="px-6 py-4 [&>svg]:top-6 [&>svg]:left-6 bg-alternative border-alternative rounded-md">
<AlertCircleIcon />
<AlertTitle_Shadcn_>
Integrations directory is only available on the Pro plan and above
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Go to your organization's billing settings and upgrade your plan to enable integrations
directory for this project
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_>
<Button size="tiny" type="default" className="mt-4" asChild>
<Link href={`/org/${selectedOrg?.slug}/billing?panel=subscriptionPlan`}>
Upgrade to Pro
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
export default IntegrationsDirectoryPlanNotice
@@ -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<z.infer<typeof FormSchema>>({
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<z.infer<typeof FormSchema>> = 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 (
<>
<SheetHeader className="py-3 flex flex-row justify-between items-center border-b-0">
<div className="flex flex-row gap-3 items-center">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle className="truncate">
{isDraft ? `Update integration` : `Add integration`}
</SheetTitle>
</div>
</SheetHeader>
<Separator />
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="w-full flex-1 overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
tabIndex={-1}
>
<Separator />
<SheetSection className="space-y-4">
{integrationEntry?.id &&
!integrationEntry?.parent_id &&
!integrationEntry?.approved && (
<Alert_Shadcn_ className="w-full mb-0" variant="warning">
<WarningIcon />
<div>
<AlertTitle_Shadcn_ className="text-sm">Awaiting Approval</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs">
Your entry awaits approval by Supabase team. In the meantime, you can see the
preview{' '}
<a
className="cursor-pointer"
href={`https://supabase.com/integrations/${integrationEntry?.slug}?preview_token=${integrationEntry?.preview_token}`}
target="_blank"
>
here
</a>
.
</AlertDescription_Shadcn_>
</div>
</Alert_Shadcn_>
)}
{integrationEntry?.parent_id && (
<Alert_Shadcn_ className="w-full mb-0" variant="warning">
<WarningIcon />
<div>
<AlertTitle_Shadcn_ className="text-sm">Draft View</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs">
Your entry updates await approval by Supabase team. In the meantime, you can see
the preview{' '}
<a
className="cursor-pointer"
href={`https://supabase.com/integrations/${integrationEntry?.slug}?preview_token=${integrationEntry?.preview_token}`}
target="_blank"
>
here
</a>
.
</AlertDescription_Shadcn_>
</div>
</Alert_Shadcn_>
)}
<div className="flex flex-row space-x-6">
<div className="w-3/4 space-y-6">
<FormField_Shadcn_
key="slug"
control={form.control}
name="slug"
render={({ field }) => (
<FormItemLayout
label="Slug"
description="The unique identifier for this integration (will be used in the URL)"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} autoFocus />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="title"
control={form.control}
name="title"
render={({ field }) => (
<FormItemLayout
label="Title"
description="A title of this integration (will be used in the search)"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<div className="w-1/4 flex items-center justify-center">
<FormField_Shadcn_
key="logo"
control={form.control}
name="logo"
render={({ field }) => (
<FormItemLayout
style={{
display: 'flex',
justifyItems: 'center',
flexDirection: 'column',
alignItems: 'center',
}}
>
<FormControl_Shadcn_>
<LogoFileInput logoUrl={field.value} onChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
<FormField_Shadcn_
key="developer"
control={form.control}
name="developer"
render={({ field }) => (
<FormItemLayout
label="Developer"
description="A name of the company/developer for this integration"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="category"
control={form.control}
name="category"
render={({ field }) => (
<FormItemLayout label="Category">
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={(value) =>
form.setValue('category', value as keyof typeof IntegrationCategory)
}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Pick a category" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.entries(IntegrationCategory).map(([key, value]) => (
<SelectItem_Shadcn_ key={key} value={key}>
{value}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="website"
control={form.control}
name="website"
render={({ field }) => (
<FormItemLayout label="Website" description="A link to your website">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="docs"
control={form.control}
name="docs"
render={({ field }) => (
<FormItemLayout label="Docs" description="A link to docs for this integration">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="video"
control={form.control}
name="video"
render={({ field }) => (
<FormItemLayout
label="Video"
description="A YouTube ID for the introduction video for this integration"
labelOptional="Optional"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="description"
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
label="Description"
description="Short description for your integration tha will be shown on the directory page."
>
<FormControl_Shadcn_>
<ExpandingTextArea {...field} className="min-h-24" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="overview"
control={form.control}
name="overview"
render={({ field }) => (
<FormItemLayout label="Overview" description="">
<FormControl_Shadcn_>
<Tabs_Shadcn_ defaultValue="write" className="w-full">
<TabsList_Shadcn_ className="grid w-full grid-cols-2">
<TabsTrigger_Shadcn_ value="write">Write</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="preview">Preview</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="write" className="">
<DroppableTextArea
value={field.value}
onChange={field.onChange}
className="min-h-[400px]"
/>
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="preview">
<Markdown content={field.value} />
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
</form>
</Form_Shadcn_>
<SheetFooter>
{!isCreating && integrationEntry?.id && (
<div className="flex-1 flex flex-row gap-2">
{integrationEntry.approved === false && (
<Button
type="danger"
onClick={() => setShowDeleteDraftDialog(true)}
loading={isLoading}
>
Discard Draft
</Button>
)}
</div>
)}
<Button type="default" onClick={() => onClosing(false)} loading={isLoading}>
Cancel
</Button>
<Button form={FORM_ID} htmlType="submit" loading={isLoading}>
{!integrationEntry?.id ? 'Create' : 'Update'}
</Button>
</SheetFooter>
<ConfirmationModal
visible={showDeleteDraftDialog}
title="Discard changes"
confirmLabel="Discard"
loading={isDeleting}
onCancel={() => setShowDeleteDraftDialog(false)}
onConfirm={() => {
deleteIntegrationEntry({ orgSlug: organization!.slug, entryId: integrationEntry?.id! })
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to discard the draft changes? This action cannot be undone.
</p>
</ConfirmationModal>
</>
)
}
@@ -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<SetStateAction<string>>
className?: string
disabled?: boolean
}
const getFile = async (fileEntry: FileSystemFileEntry) => {
try {
return await new Promise<File>((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<HTMLInputElement>(null)
const [areFilesValid, setAreFilesValid] = useState<'none' | 'valid' | 'invalid'>('none')
const onDragOver: DragEventHandler<HTMLTextAreaElement> = (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}…]()`, `![${file.name}](${url})`)
})
})
})
}
const onDrop: DragEventHandler<HTMLTextAreaElement> = async (event) => {
onDragOver(event)
if (areFilesValid === 'valid') {
const items = (event as any).target.files || (event as any).dataTransfer.items
uploadFiles(items)
}
}
const checkFiles: DragEventHandler<HTMLTextAreaElement> = (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<HTMLInputElement> = 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 (
<div>
<TextArea
disabled={disabled}
onDragEnter={(e) => checkFiles(e)}
onDragExit={() => setAreFilesValid('none')}
onDragOver={onDragOver}
onDrop={onDrop}
className={cn(
areFilesValid === 'valid'
? 'border-green-700'
: areFilesValid === 'invalid'
? 'border-red-700'
: '',
className
)}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<input
ref={fileInputRef}
type="file"
multiple
hidden
accept={VALID_FILE_EXTENSIONS.join(', ')}
onChange={onInputChange}
/>
<div className="px-1 pt-2">
<span className="text-sm text-foreground-lighter">
Extended description for your integration that will be shown on your page. Supports
markdown formatting. Attach files by dragging &amp; dropping them or{' '}
<a className="underline cursor-pointer" onClick={onUploadClick}>
click here
</a>{' '}
to upload.
</span>
</div>
</div>
)
}
@@ -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<HTMLInputElement>(null)
const onFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
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 ? (
<div
className={cn(
'shadow transition group relative',
'bg-center bg-cover bg-no-repeat',
'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center'
)}
style={{
backgroundImage: `url("${logoUrl}")`,
}}
>
<div className="absolute bottom-1 right-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1">
<IconEdit />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="upload"
onClick={() => {
fileInputRef.current?.click()
}}
>
<p>Upload image</p>
</DropdownMenuItem>
<DropdownMenuItem key="remove" onClick={() => onChange(null)}>
<p>Remove image</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
) : (
<div
className={cn(
'border border-strong transition opacity-75 hover:opacity-100',
'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center cursor-pointer'
)}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={18} strokeWidth={1.5} className="text-foreground" />
<p className="text-xs text-foreground-light">Upload logo</p>
</div>
)}
<input
multiple
type="file"
ref={fileInputRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={onFileUpload}
/>
</>
)
}
@@ -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 (
<ScaffoldContainer>
<ScaffoldSection>
<ScaffoldSectionDetail title="Integrations Directory Entry">
<p>
Add your own integrations to{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations`}
target="_blank"
>
our Integrations Directory
</a>
</p>
</ScaffoldSectionDetail>
{isFreePlan && (
<ScaffoldSectionContent>
<IntegrationsDirectoryPlanNotice />
</ScaffoldSectionContent>
)}
{!isFreePlan && (
<ScaffoldSectionContent>
{isLoading && <div>Loading...</div>}
{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 && (
<div className="prose text-sm">
<p>
If you want your integration to be listed in{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations`}
target="_blank"
>
our Integrations Directory
</a>
, please submit an application. We will review it and get back to you.
</p>
<Button type="primary" className="w-fit" onClick={() => setVisible(true)}>
Submit an application
</Button>
</div>
)}
{!entry && draftEntry && (
<div className="prose text-sm">
<p>
Your entry awaits approval by Supabase team. In the meantime, you can see the
preview{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/${draftEntry?.slug}?preview_token=${draftEntry?.preview_token}`}
target="_blank"
>
here
</a>
. Once you're finished with the draft and want to submit the draft for review,
please contact us via support.
</p>
<Button type="primary" className="w-fit" onClick={() => setVisible(true)}>
Edit draft for review
</Button>
</div>
)}
{entry && !draftEntry && (
<div className="prose text-sm text-foreground-light">
<p>
Your integration entry is live at{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/${entry.slug}`}
target="_blank"
>
{process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/{entry.slug}
</a>
. If you want to remove your integration from our directory,{' '}
<a className="cursor-pointer" onClick={() => setShowDeleteEntryDialog(true)}>
click here.
</a>
</p>
<Button type="primary" className="w-fit" onClick={() => setVisible(true)}>
Submit draft for review
</Button>
</div>
)}
{entry && draftEntry && (
<>
<div className="prose text-sm text-foreground-light">
Your integration entry is live at{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/${entry.slug}`}
target="_blank"
>
{process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/{entry.slug}
</a>
. If you want to remove your integration from our directory,{' '}
<a className="cursor-pointer" onClick={() => setShowDeleteEntryDialog(true)}>
click here.
</a>{' '}
</div>
<div className="prose text-sm">
<p>
Your entry updates await approval by Supabase team. In the meantime, you can
see the preview{' '}
<a
className="cursor-pointer"
href={`${process.env.NEXT_PUBLIC_SITE_URL}/partners/integrations/${draftEntry?.slug}?preview_token=${draftEntry?.preview_token}`}
target="_blank"
>
here
</a>
. Once you're finished with the draft and want to submit the draft for
review, please contact us via support.
</p>
<Button type="primary" className="w-fit" onClick={() => setVisible(true)}>
Edit draft for review
</Button>
</div>
</>
)}
<ConfirmationModal
visible={showDeleteEntryDialog}
title="Delete integration entry"
confirmLabel="Delete"
loading={isDeleting}
onCancel={() => setShowDeleteEntryDialog(false)}
onConfirm={() => {
if (entry) {
deleteIntegrationEntry({
orgSlug: organization!.slug,
entryId: entry?.id,
})
}
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to delete the integration entry? This action cannot be
undone.
</p>
</ConfirmationModal>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setVisible(false)
setHasChanges(false)
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your
changes will be lost.
</p>
</ConfirmationModal>
<Sheet open={visible} onOpenChange={closeSheet}>
<SheetContent
showClose={false}
className="flex flex-col gap-0"
tabIndex={undefined}
>
<CreateIntegrationSheet
onChange={(value) => {
if (value !== hasChanges) {
setHasChanges(value)
}
}}
onClosing={closeSheet}
integrationEntry={draftEntry || entry}
/>
</SheetContent>
</Sheet>
</>
)}
</ScaffoldSectionContent>
)}
</ScaffoldSection>
</ScaffoldContainer>
)
}
export default SupabaseSection
@@ -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
}
@@ -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<typeof createIntegrationDirectoryEntry>
>
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,
})
}
@@ -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<typeof deleteIntegrationDirectoryEntry>
>
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,
})
}
@@ -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<typeof updateIntegrationDirectoryEntry>
>
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,
})
}
@@ -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<ReturnType<typeof getIntegrationsDirectory>>
export type IntegrationsError = ResponseError
export const useIntegrationsDirectoryQuery = <TData = IntegrationsDirectoryData>(
vars: IntegrationsDirectoryVariables,
{
enabled = true,
...options
}: UseQueryOptions<IntegrationsDirectoryData, IntegrationsError, TData> = {}
) =>
useQuery<IntegrationsDirectoryData, IntegrationsError, TData>(
integrationsDirectoryKeys.integrationsDirectoryList(vars.orgSlug),
({ signal }) => getIntegrationsDirectory(vars, signal),
{ enabled: enabled, ...options }
)
@@ -0,0 +1,3 @@
export const integrationsDirectoryKeys = {
integrationsDirectoryList: (orgSlug: string) => ['integrations-directory', orgSlug] as const,
}
@@ -5,10 +5,13 @@ export default function BecomeAPartner() {
return (
<div className="border-t">
<div id="become-a-partner" className="mx-auto max-w-2xl space-y-12 py-32 px-6 text-center">
<h2 className="h2">Ready to work together?</h2>
<h2 className="h2">Want to share with the community?</h2>
<Button asChild size="medium" className="text-white">
<Link href="https://forms.supabase.com/partner" as="https://forms.supabase.com/partner">
Become a partner
<Link
href="https://supabase.com/dashboard/org/_/integrations"
as="https://supabase.com/dashboard/org/_/integrations"
>
List your integration
</Link>
</Button>
</div>
+28 -1
View File
@@ -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 (
<span className="text-xs h-12 w-12 bg-alternative rounded-md p-1 mr-2 relative -top-0.5">
New
</span>
)
}
return null
}
export default function TileGrid({
partners,
hideCategories = false,
@@ -49,6 +70,7 @@ export default function TileGrid({
</div>
<div>
<h3 className="text-foreground-light group-hover:text-foreground mb-2 text-xl transition-colors">
{renderNewPill(p)}
{p.title}
</h3>
<p
@@ -67,7 +89,11 @@ export default function TileGrid({
) : null}
{Object.keys(partnersByCategory).map((category) => (
<div key={category} id={category.toLowerCase()} className="space-y-8">
{!hideCategories && <h2 className="h2">{category}</h2>}
{!hideCategories && (
<h2 className="h2">
{IntegrationCategory[category as keyof typeof IntegrationCategory]}
</h2>
)}
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{partnersByCategory[category].map((p) => (
<Link key={p.slug} href={`/partners/${p.slug}`}>
@@ -92,6 +118,7 @@ export default function TileGrid({
</div>
<div>
<h3 className="text-light group-hover:text-foreground mb-2 text-xl transition-colors">
{renderNewPill(p)}
{p.title}
</h3>
<p
+2 -2
View File
@@ -58,8 +58,8 @@ const nextConfig = {
'images.unsplash.com',
'img.youtube.com',
'vercel.com',
'obuldanrptloktxcffvn.supabase.co',
'xguihxuzqibwxjnimxev.supabase.co',
'obuldanrptloktxcffvn.supabase.co', // 'misc use' project
'xguihxuzqibwxjnimxev.supabase.co', // '[LIVE] supabase-com' project
'pbs.twimg.com',
'res.cloudinary.com',
's3-us-west-2.amazonaws.com',
+14 -19
View File
@@ -1,34 +1,29 @@
import { GetStaticPaths, GetStaticProps } from 'next'
import type { GetServerSideProps } from 'next'
import supabase from '~/lib/supabaseMisc'
import Error404 from '../404'
import { API_URL } from '~/lib/constants'
import type { IntegrationsDirectoryEntry } from './integrations'
function PartnerPage() {
// Should be redirected to ./experts/:slug or ./integrations/:slug
return <Error404 />
}
// 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('*')
+57 -45
View File
@@ -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<SetStateAction<string | null>>) {
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<string, unknown>, Record<string, unknown>>
isPreview: boolean
}) {
const [focusedImage, setFocusedImage] = useState<string | null>(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 && <PartnerDetails partner={partner} />}
<div className="lg:col-span-5 overflow-hidden">
{isPreview && (
<div className="bg-surface-200 w-full p-4 mb-12 rounded-2xl border">
<p className="text-sm">
This is a preview page managed by the partner and has not been approved by
Supabase team.
</p>
</div>
)}
<h2
className="text-foreground"
style={{ fontSize: '1.5rem', marginBottom: '1rem' }}
@@ -193,15 +207,16 @@ function Partner({
{!isNarrow && <PartnerDetails partner={partner} />}
</div>
{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 && (
<div className="bg-background hover:border-default-control border-default rounded-2xl border p-10 drop-shadow-sm max-w-5xl mx-auto mt-12">
<div className="flex flex-row justify-between">
<h1 className="text-2xl font-medium self-center">
Get started with {partner.title} and Supabase.
</h1>
<a href={partner.call_to_action_link} target="_blank" rel="noreferrer">
<a href={partner.docs} target="_blank" rel="noreferrer">
<Button size="medium" type="secondary">
Add integration
See the docs
</Button>
</a>
</div>
@@ -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 }) => {
)}
<div className="text-foreground divide-y">
{partner.type === 'technology' && (
{/* TODO(Kamil): This has changed to be a mandatory field. Remove this guard once we move all the data. */}
{partner.developer && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Developer</span>
<span className="text-foreground">{partner.developer}</span>
@@ -267,7 +283,7 @@ const PartnerDetails = ({ partner }: { partner: Partner }) => {
</a>
</div>
{partner.type === 'technology' && partner.docs && (
{partner.docs && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Documentation</span>
<a
@@ -292,45 +308,42 @@ const PartnerDetails = ({ partner }: { partner: Partner }) => {
)
}
// 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 },
}
}
+85 -41
View File
@@ -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]}
</button>
))}
</div>
@@ -171,10 +215,10 @@ function IntegrationPartnersPage(props: Props) {
/>
<PartnerLinkBox
href={`/partners/integrations#become-a-partner`}
title="Become a partner"
href={`https://supabase.com/dashboard/org/_/integrations`}
title="List your integration"
color="brand"
description="Fill out a quick 30 second form to apply to become a partner"
description="Add your entry to our Integrations Directory and become listed here"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
+220
View File
@@ -203,6 +203,14 @@ export interface paths {
/** Gets user's integrations */
get: operations['IntegrationsController_getUserInstallations']
}
'/platform/integrations-directory': {
/** Retrieves an integrations directory list */
get: operations['IntegrationsDirectoryController_getIntegrationsDirectory']
}
'/platform/integrations-directory/{slug}': {
/** Retrieves integrations directory entry by slug */
get: operations['IntegrationsDirectoryController_getIntegrationsDirectoryEntry']
}
'/platform/integrations/{slug}': {
/** Gets integration with the given organization slug */
get: operations['IntegrationsController_getUserInstallationForOrg']
@@ -360,6 +368,18 @@ export interface paths {
/** Get standard security questionnaire URL */
get: operations['OrgDocumentsController_getStandardSecurityQuestionnaireUrl']
}
'/platform/organizations/{slug}/integrations-directory': {
/** Retrieves integrations directory entry for a given organization slug */
get: operations['OrgIntegrationsDirectoryController_getIntegrationsDirectoryEntry']
/** Updates integrations directory entry by creating a new pending draft */
put: operations['OrgIntegrationsDirectoryController_updateIntegrationsDirectoryEntry']
/** Creates integrations directory entry */
post: operations['OrgIntegrationsDirectoryController_createIntegrationsDirectoryEntry']
}
'/platform/organizations/{slug}/integrations-directory/{entry_id}': {
/** Deletes integrations directory entry or its pending draft */
delete: operations['OrgIntegrationsDirectoryController_deleteIntegrationsDirectoryEntry']
}
'/platform/organizations/{slug}/members': {
/** Gets organization's members */
get: operations['MembersController_getMembers']
@@ -3217,6 +3237,23 @@ export interface components {
GetContentCountResponse: {
count: number
}
GetIntegrationsDirectoryEntryResponse: {
approved: boolean
category: components['schemas']['IntegrationsDirectoryCategory']
description: string
developer: string
docs: string
featured: boolean
logo: string
overview: string
slug: string
title: string
video: string | null
website: string
}
GetIntegrationsDirectoryResponse: {
entries: components['schemas']['IntegrationsDirectoryListItem'][]
}
GetMetricsBody: {
/** @enum {string} */
interval: '1d' | '3d' | '7d'
@@ -3641,6 +3678,45 @@ export interface components {
metadata: Record<string, never>
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: {
@@ -4,16 +4,14 @@ import { cn } from '../../../lib/utils/cn'
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const customClasses = ['bg-control']
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-10 w-full rounded-md border border-control bg-control px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-foreground-muted',
'flex min-h-10 w-full rounded-md border border-control bg-foreground/[.026] px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-foreground-muted',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-background-control focus-visible:ring-offset-2 focus-visible:ring-offset-foreground-muted disabled:cursor-not-allowed disabled:opacity-50',
...customClasses,
'aria-[invalid=true]:bg-destructive-200 aria-[invalid=true]:border-destructive-400 aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus-visible:border-destructive',
className
)}
ref={ref}