mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 23:29:08 +03:00
feat: Integrations Directory
This commit is contained in:
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}
|
||||
|
||||
+6
@@ -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 />
|
||||
</>
|
||||
|
||||
+33
@@ -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
|
||||
+491
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+176
@@ -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}…]()`, ``)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 & dropping them or{' '}
|
||||
<a className="underline cursor-pointer" onClick={onUploadClick}>
|
||||
click here
|
||||
</a>{' '}
|
||||
to upload.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+101
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+259
@@ -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
|
||||
}
|
||||
+66
@@ -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,
|
||||
})
|
||||
}
|
||||
+70
@@ -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,
|
||||
})
|
||||
}
|
||||
+66
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('*')
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+220
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user