Files
supabase/apps/www/pages/features.tsx
Gildas GarciaandIvan Vasilov 96d43099bb chore: refactor Button API so that it can be used a standard button (#46880)
## Problem

Our `<Button>` component breaks the default `button` contract by
redefining the `type` prop to set its variant (`primary`, `default`,
etc) instead of the button type (`submit`, `button`, etc).
This is confusing and forces to write more code when using it with
shadcn components that expect/inject the standard button props.

## Solution

- rename the `type` prop to `variant`
- rename the `htmlType` prop to `type`
- propagate the changes where necessary
- format code

## How to test

As this is just prop renaming, if it builds it's ok

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-16 23:59:58 +02:00

333 lines
15 KiB
TypeScript

import { motion } from 'framer-motion'
import { debounce } from 'lib/helpers'
import { LayoutGrid, Search, Table2 } from 'lucide-react'
import { NextSeo } from 'next-seo'
import { useRouter } from 'next/compat/router'
import Head from 'next/head'
import Link from 'next/link'
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
import { Badge, Button, Checkbox, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
import {
FeaturesMatrix,
productLabel,
stageBadgeVariant,
stageLabel,
} from '@/components/FeaturesMatrix'
import DefaultLayout from '@/components/Layouts/Default'
import SectionContainer from '@/components/Layouts/SectionContainer'
import Panel from '@/components/Panel'
import { features } from '@/data/features'
import { breadcrumbs } from '@/lib/breadcrumbs'
import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld'
type ViewMode = 'grid' | 'matrix'
function FeaturesPage() {
const router = useRouter()
const [searchTerm, setSearchTerm] = useState<string>((router?.query.q as string) || '')
const [selectedProducts, setSelectedProducts] = useState<string[]>(
(router?.query.products as string)?.split(',') || []
)
const [showSelfHostedOnly, setShowSelfHostedOnly] = useState<boolean>(
router?.query.selfHosted === 'true'
)
const [viewMode, setViewMode] = useState<ViewMode>(
(router?.query.view as ViewMode) === 'matrix' ? 'matrix' : 'grid'
)
const HAS_ACTIVE_FILTERS = selectedProducts.length || searchTerm.length || showSelfHostedOnly
const products = Array.from(new Set(features.flatMap((feature) => feature.products)))
// Debounced function to update URL params
const updateQueryParamsDebounced = useCallback(
debounce(() => updateQueryParams(), 300),
[searchTerm, selectedProducts, showSelfHostedOnly, viewMode]
)
const updateQueryParams = () => {
const params = new URLSearchParams()
if (searchTerm) params.set('q', searchTerm)
if (selectedProducts.length > 0) params.set('products', selectedProducts.join(','))
if (showSelfHostedOnly) params.set('selfHosted', 'true')
if (viewMode === 'matrix') params.set('view', 'matrix')
router?.replace({ pathname: '/features', query: params.toString() }, undefined, {
shallow: true,
})
}
// Apply filters based on initial URL params on mount
useEffect(() => {
updateQueryParamsDebounced()
return updateQueryParamsDebounced.cancel // Cleanup on unmount
}, [searchTerm, selectedProducts, updateQueryParamsDebounced])
// Sync state with query parameters when they change
useEffect(() => {
if (router?.query.q !== searchTerm) setSearchTerm((router?.query.q as string) || '')
if (router?.query.products !== selectedProducts.join(',')) {
setSelectedProducts((router?.query.products as string)?.split(',') || [])
}
const selfHostedParam = router?.query.selfHosted === 'true'
if (selfHostedParam !== showSelfHostedOnly) setShowSelfHostedOnly(selfHostedParam)
const viewParam = (router?.query.view as ViewMode) === 'matrix' ? 'matrix' : 'grid'
if (viewParam !== viewMode) setViewMode(viewParam)
}, [router?.query.q, router?.query.products, router?.query.selfHosted, router?.query.view])
// Handle search input change
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
// Handle product checkbox change
const handleProductChange = (product: string) => {
setSelectedProducts((prev) =>
prev.includes(product) ? prev.filter((p) => p !== product) : [...prev, product]
)
}
// Filter features based on search term and selected products
const filteredFeatures = features.filter((feature) => {
const matchesSearch =
feature.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
feature.subtitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
feature.description.toLowerCase().includes(searchTerm.toLowerCase())
const matchesProduct =
selectedProducts.length === 0 ||
feature.products.some((product) => selectedProducts.includes(product))
const matchesSelfHosted = !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true
return matchesSearch && matchesProduct && matchesSelfHosted
})
const meta = {
title: 'Supabase Features',
description:
'From authentication to storage, everything you need to build and ship your next project.',
}
return (
<>
<NextSeo
title={meta.title}
description={meta.description}
openGraph={{
title: meta.title,
description: meta.description,
url: '/customers',
}}
/>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.features)),
}}
/>
</Head>
<DefaultLayout>
<SectionContainer className="py-0! sm:px-0!">
<div className="border border-muted rounded-xl bg-alternative my-4 px-6 py-8 md:py-10 lg:px-16 lg:py-20 xl:px-20 bg-center bg-cover bg-[url('/images/features/features-cover-light.svg')] dark:bg-[url('/images/features/features-cover-dark.svg')]">
<motion.div
className="mx-auto sm:max-w-xl text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.5, easing: 'easeOut' } }}
>
<h1 className="h1 text-foreground m-0!">Supabase Features</h1>
<p className="text-foreground-light text-base">
Everything you need <br className="md:hidden" /> to build and ship your next
project.
</p>
</motion.div>
</div>
</SectionContainer>
<SectionContainer className="relative grid md:grid-cols-4 md:gap-4 pt-0!">
<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 features"
value={searchTerm}
onChange={handleSearchChange}
/>
</InputGroup>
<div className="hidden md:flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-foreground-light hover:text-foreground cursor-pointer! hover:cursor-pointer! transition-colors">
<Checkbox
id="self-hosted-filter"
checked={showSelfHostedOnly}
onCheckedChange={() => setShowSelfHostedOnly(!showSelfHostedOnly)}
className="[&_input]:m-0"
/>
<label
htmlFor="self-hosted-filter"
className="text-sm leading-none! flex-1 text-left"
>
Show only self-hosted features
</label>
</div>
</div>
<div className="hidden md:flex flex-col gap-4">
<h2 className="text-sm text-foreground-lighter">Filter by tags:</h2>
<div className="flex flex-col gap-2.5">
{products
.sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1))
.map((product) => (
<div
key={product}
className="flex items-center gap-2 text-foreground-light hover:text-foreground cursor-pointer! hover:cursor-pointer! transition-colors"
>
<Checkbox
id={product}
checked={selectedProducts.includes(product)}
onCheckedChange={() => handleProductChange(product)}
className="[&_input]:m-0"
/>
<label
htmlFor={product}
className="text-sm leading-none! capitalize flex-1 text-left"
>
{product}
</label>
</div>
))}
</div>
<div className="text-foreground-muted text-xs">
Features selected: {filteredFeatures.length}
</div>
</div>
<Button
tabIndex={HAS_ACTIVE_FILTERS ? 0 : -1}
block
variant="dashed"
onClick={() => {
setSelectedProducts([])
setSearchTerm('')
setShowSelfHostedOnly(false)
}}
className={cn(
'opacity-0 transition-opacity hidden md:block',
HAS_ACTIVE_FILTERS && 'block! opacity-100'
)}
>
Clear all filters
</Button>
</div>
</div>
<div className="md:col-span-3 min-w-0 flex flex-col gap-4 md:gap-6">
<div className="flex items-center justify-between gap-2">
<span className="text-foreground-muted text-xs">
{filteredFeatures.length} feature{filteredFeatures.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'
)}
>
<LayoutGrid size={14} />
</button>
<button
title="Matrix view"
onClick={() => setViewMode('matrix')}
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 === 'matrix'
? 'bg-surface-300 text-foreground'
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
)}
>
<Table2 size={14} />
</button>
</div>
</div>
{viewMode === 'matrix' ? (
<FeaturesMatrix features={filteredFeatures} />
) : !filteredFeatures?.length ? (
<p className="text-foreground-lighter text-sm">
No features found with these filters
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
{filteredFeatures
.sort((a, b) => (a.title > b.title ? 1 : -1))
.map((feature) => (
<Link
key={`feat-${feature.title}`}
href={`/features/${feature.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">
<feature.icon className="w-5 h-5 text-foreground-light group-hover:text-foreground transition-colors" />
{feature.status && (
<div className="hidden md:block absolute bottom-1.5 left-1.5">
<Badge
variant={stageBadgeVariant(feature.status.stage)}
className="text-[10px] py-0 px-1.5 h-4 rounded-sm"
>
{stageLabel(feature.status.stage)}
</Badge>
</div>
)}
</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">
<h3 className="text-sm md:text-base text-foreground leading-5!">
{feature.title}
</h3>
<p className="text-foreground-light text-sm line-clamp-2">
{feature.subtitle}
</p>
<div className="flex flex-wrap items-center gap-1 mb-0.5">
{feature.status && (
<Badge
variant={stageBadgeVariant(feature.status.stage)}
className="md:hidden text-[10px] py-0 px-1.5 h-4 rounded-sm"
>
{stageLabel(feature.status.stage)}
</Badge>
)}
{feature.products.map((product) => (
<span
key={product}
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"
>
{productLabel(product)}
</span>
))}
</div>
</div>
</Panel>
</Link>
))}
</div>
)}
</div>
</SectionContainer>
</DefaultLayout>
</>
)
}
export default FeaturesPage