mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
update partner catalogue layout
This commit is contained in:
1 file changed
+266
-138
@@ -2,13 +2,14 @@
|
||||
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import Panel from '~/components/Panel'
|
||||
import { searchPartners } from '~/lib/marketplaceDb'
|
||||
import type { Partner } from '~/types/partners'
|
||||
import { ArrowRight, ArrowUpRight, Loader, Search } from 'lucide-react'
|
||||
import { ArrowRight, ArrowUpRight, LayoutGrid, List, Loader, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Badge, Button, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
import { Badge, Button, Checkbox, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +18,8 @@ interface Props {
|
||||
metaDescription: string
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
export default function IntegrationsContent({
|
||||
initialPartners,
|
||||
metaTitle,
|
||||
@@ -25,12 +28,13 @@ export default function IntegrationsContent({
|
||||
const [partners, setPartners] = useState(initialPartners)
|
||||
const allCategories = Array.from(
|
||||
new Set(initialPartners?.flatMap((p) => p.categories.map((c) => c.name)))
|
||||
)
|
||||
).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearchTerm] = useDebounce(search, 300)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const searchIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,183 +43,307 @@ export default function IntegrationsContent({
|
||||
setPartners(initialPartners)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
const currentSearchId = ++searchIdRef.current
|
||||
|
||||
searchPartners(debouncedSearchTerm)
|
||||
.then((results) => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setPartners(results ?? [])
|
||||
}
|
||||
if (currentSearchId === searchIdRef.current) setPartners(results ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setPartners([])
|
||||
}
|
||||
if (currentSearchId === searchIdRef.current) setPartners([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (currentSearchId === searchIdRef.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
if (currentSearchId === searchIdRef.current) setIsSearching(false)
|
||||
})
|
||||
}, [debouncedSearchTerm, initialPartners])
|
||||
|
||||
const featuredPartners = partners.filter((p) => p.featured).slice(0, 6)
|
||||
const featuredSlugs = new Set(featuredPartners.map((p) => p.slug))
|
||||
const hasSearchQuery = search.trim() !== ''
|
||||
const isFiltered = hasSearchQuery || selectedCategory !== null
|
||||
const showFeatured = !isFiltered && featuredPartners.length > 0
|
||||
const listPartners = hasSearchQuery
|
||||
? partners
|
||||
: selectedCategory
|
||||
? partners.filter((p) => p.categories.some((c) => c.name === selectedCategory))
|
||||
: showFeatured
|
||||
? partners.filter((p) => !featuredSlugs.has(p.slug))
|
||||
: partners
|
||||
const handleCategoryChange = (category: string) =>
|
||||
setSelectedCategories((prev) =>
|
||||
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||
)
|
||||
|
||||
const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0
|
||||
|
||||
const categoryFiltered =
|
||||
selectedCategories.length > 0
|
||||
? partners.filter((p) => p.categories.some((c) => selectedCategories.includes(c.name)))
|
||||
: partners
|
||||
|
||||
const featuredPartners = categoryFiltered.filter((p) => p.featured)
|
||||
const listPartners = HAS_ACTIVE_FILTERS
|
||||
? [
|
||||
...categoryFiltered.filter((p) => p.featured),
|
||||
...categoryFiltered.filter((p) => !p.featured),
|
||||
]
|
||||
: categoryFiltered.filter((p) => !p.featured)
|
||||
const showFeatured = !HAS_ACTIVE_FILTERS && featuredPartners.length > 0
|
||||
const totalCount = showFeatured
|
||||
? featuredPartners.length + listPartners.length
|
||||
: listPartners.length
|
||||
|
||||
return (
|
||||
<DefaultLayout className="bg-alternative">
|
||||
<SectionContainer className="space-y-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<DefaultLayout>
|
||||
<SectionContainer>
|
||||
<div className="flex flex-col gap-3 mb-8">
|
||||
<span className="text-brand font-mono uppercase tracking-widest text-sm">
|
||||
Partner Catalog
|
||||
</span>
|
||||
<h1 className="h1 !mb-0">{metaTitle}</h1>
|
||||
<p className="text-foreground-lighter text-xl max-w-2xl">{metaDescription}</p>
|
||||
<h1 className="h1 mb-0!">{metaTitle}</h1>
|
||||
<p className="text-foreground-lighter text-xl max-w-2xl text-balance">
|
||||
{metaDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Search + category pills (sticky) */}
|
||||
<div className="sticky top-16 z-20 -mx-6 px-6 py-4 lg:-mx-16 lg:px-16 xl:-mx-20 xl:px-20 bg-alternative/90 backdrop-blur-md flex flex-col gap-4">
|
||||
<div className="max-w-md">
|
||||
<div className="relative grid md:grid-cols-4 md:gap-4">
|
||||
{/* Left sidebar — sticky search + filters */}
|
||||
<div className="relative w-full h-full">
|
||||
<div className="mb-4 flex flex-col gap-4 sticky top-20">
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
type="search"
|
||||
placeholder="Search partners..."
|
||||
placeholder="Search integrations"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
{isSearching && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="mr-1 animate-spin text-white">
|
||||
<Loader />
|
||||
</span>
|
||||
<Loader size={14} className="animate-spin" />
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
|
||||
<div className="hidden md:flex flex-col gap-4">
|
||||
<h2 className="text-sm text-foreground-lighter">Filter by category:</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{allCategories.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center gap-2 text-foreground-light hover:text-foreground cursor-pointer! transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={`cat-${category}`}
|
||||
checked={selectedCategories.includes(category)}
|
||||
onCheckedChange={() => handleCategoryChange(category)}
|
||||
className="[&_input]:m-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`cat-${category}`}
|
||||
className="text-sm leading-none! flex-1 text-left"
|
||||
>
|
||||
{category}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-foreground-muted text-xs">
|
||||
Integrations shown: {totalCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
tabIndex={HAS_ACTIVE_FILTERS ? 0 : -1}
|
||||
block
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setSelectedCategories([])
|
||||
setSearch('')
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-sm transition-colors',
|
||||
selectedCategory === null
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-background hover:bg-surface-100 text-foreground-light'
|
||||
'opacity-0 transition-opacity hidden md:block',
|
||||
HAS_ACTIVE_FILTERS && 'block! opacity-100'
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-sm transition-colors',
|
||||
selectedCategory === category
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-background hover:bg-surface-100 text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
Clear all filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured grid — shown when not filtering */}
|
||||
{showFeatured && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{featuredPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/integrations/${p.slug}`}
|
||||
className="group flex h-full flex-col gap-3 rounded-xl border bg-surface-100 p-5 transition-colors hover:bg-surface-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<h3 className="text-foreground text-base font-medium tracking-tight">
|
||||
{p.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{p.categories.map((c) => (
|
||||
<Badge>{c.name}</Badge>
|
||||
))}
|
||||
{/* Right: content */}
|
||||
<div className="md:col-span-3 min-w-0 flex flex-col gap-4 md:gap-6">
|
||||
{/* Featured partners */}
|
||||
{showFeatured && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-sm text-foreground-lighter">Featured</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{featuredPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/integrations/${p.slug}`}
|
||||
className="group flex h-full flex-col gap-3 rounded-xl border bg-surface-100 p-5 transition-colors hover:bg-surface-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<h3 className="text-foreground text-base font-medium tracking-tight">
|
||||
{p.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{p.categories.map((c) => (
|
||||
<Badge key={c.name}>{c.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm line-clamp-3 text-pretty">
|
||||
{p.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-foreground-lighter text-sm line-clamp-3 text-pretty">
|
||||
{p.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact list */}
|
||||
{listPartners.length ? (
|
||||
<div className="border bg-background rounded-xl overflow-hidden divide-y">
|
||||
{listPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/integrations/${p.slug}`}
|
||||
className="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-surface-100"
|
||||
{/* Toolbar: count + view toggle */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-foreground-muted text-xs">
|
||||
{listPartners.length} integration{listPartners.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex items-center rounded-lg border border-muted">
|
||||
<button
|
||||
title="Grid view"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 transition-colors rounded-l-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
viewMode === 'grid'
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image src={p.logo} alt={p.title} fill className="object-cover" sizes="40px" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground text-sm font-medium tracking-tight">
|
||||
{p.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{p.categories.map((c) => (
|
||||
<Badge>{c.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm truncate">{p.description}</p>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={16}
|
||||
className="shrink-0 text-foreground-lighter transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
title="List view"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 transition-colors border-l border-muted rounded-r-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
viewMode === 'list'
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter">No partners found.</p>
|
||||
)}
|
||||
|
||||
{/* Grid view */}
|
||||
{viewMode === 'grid' &&
|
||||
(listPartners.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
||||
{listPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/integrations/${p.slug}`}
|
||||
className="flex flex-col justify-start items-stretch group cursor-pointer transition rounded-xl focus-visible:ring-2 focus-visible:ring-foreground-lighter outline-hidden outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-foreground-lighter"
|
||||
>
|
||||
<Panel
|
||||
hasActiveOnHover
|
||||
outerClassName="h-full"
|
||||
innerClassName="flex md:flex-col gap-3 sm:gap-2 h-full items-start p-2"
|
||||
>
|
||||
<div className="relative rounded-lg min-h-[80px] max-h-[80px] md:max-h-[140px] h-full md:h-auto aspect-square md:w-full md:aspect-video! bg-alternative flex items-center justify-center shadow-inner border border-muted overflow-hidden shrink-0 md:shrink">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-contain p-6 lg:p-10"
|
||||
sizes="(max-width: 768px) 80px, 200px"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:p-2 md:pt-1 flex flex-col h-full md:h-auto grow gap-0.5 md:gap-1.5 justify-center md:justify-start min-w-0">
|
||||
<h3 className="text-sm md:text-base text-foreground leading-5!">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="text-foreground-light text-sm line-clamp-2 flex-1">
|
||||
{p.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{p.categories.map((c) => (
|
||||
<span
|
||||
key={c.name}
|
||||
className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-surface-200 text-foreground-light border border-muted capitalize"
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
No integrations found with these filters.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{/* List view */}
|
||||
{viewMode === 'list' &&
|
||||
(listPartners.length ? (
|
||||
<div className="border bg-background rounded-xl overflow-hidden divide-y">
|
||||
{listPartners.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/partners/integrations/${p.slug}`}
|
||||
className="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-surface-100"
|
||||
>
|
||||
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={p.logo}
|
||||
alt={p.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="40px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-foreground text-sm font-medium tracking-tight">
|
||||
{p.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{p.categories.map((c) => (
|
||||
<span
|
||||
key={c.name}
|
||||
className="inline-flex items-center text-[10px] font-medium px-1.5 py-0 h-4 rounded bg-surface-200 text-foreground-light border border-muted capitalize"
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground-lighter text-sm truncate">{p.description}</p>
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={16}
|
||||
className="shrink-0 text-foreground-lighter transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
No integrations found with these filters.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
|
||||
<div className="border-t bg-background">
|
||||
<div
|
||||
id="become-a-partner"
|
||||
|
||||
Reference in new issue
Block a user