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, ToggleGroup, ToggleGroupItem, } 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' // Relevance weights used to rank search results. Title matches are considered // far more important than body content, so a feature whose title matches the // query surfaces above features where the term only appears in the description. // Title weights are deliberately larger than the maximum combined body score // (SUBTITLE_INCLUDES + DESCRIPTION_INCLUDES), so any title match always ranks // above a feature that only matches in its subtitle and/or description. const SEARCH_WEIGHT = { TITLE_STARTS_WITH: 5, TITLE_INCLUDES: 4, SUBTITLE_INCLUDES: 2, DESCRIPTION_INCLUDES: 1, } as const // Score a feature against a (lowercased) search term. Higher is more relevant. // Returns 0 when nothing matches. function getSearchScore(feature: (typeof features)[number], term: string): number { if (!term) return 0 const title = feature.title.toLowerCase() const subtitle = feature.subtitle.toLowerCase() const description = feature.description.toLowerCase() let score = 0 if (title.startsWith(term)) { score += SEARCH_WEIGHT.TITLE_STARTS_WITH } else if (title.includes(term)) { score += SEARCH_WEIGHT.TITLE_INCLUDES } if (subtitle.includes(term)) score += SEARCH_WEIGHT.SUBTITLE_INCLUDES if (description.includes(term)) score += SEARCH_WEIGHT.DESCRIPTION_INCLUDES return score } function FeaturesPage() { const router = useRouter() const [searchTerm, setSearchTerm] = useState((router?.query.q as string) || '') const [selectedProducts, setSelectedProducts] = useState( (router?.query.products as string)?.split(',') || [] ) const [showSelfHostedOnly, setShowSelfHostedOnly] = useState( router?.query.selfHosted === 'true' ) const [viewMode, setViewMode] = useState( (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) => { 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 normalizedSearchTerm = searchTerm.toLowerCase() const filteredFeatures = features .filter((feature) => { const matchesSearch = !normalizedSearchTerm || getSearchScore(feature, normalizedSearchTerm) > 0 const matchesProduct = selectedProducts.length === 0 || feature.products.some((product) => selectedProducts.includes(product)) const matchesSelfHosted = !showSelfHostedOnly || feature.status?.availableOnSelfHosted === true return matchesSearch && matchesProduct && matchesSelfHosted }) // When searching, rank by relevance (title matches weigh more than body // content), falling back to alphabetical order for ties and no search. .sort((a, b) => { if (normalizedSearchTerm) { const scoreDiff = getSearchScore(b, normalizedSearchTerm) - getSearchScore(a, normalizedSearchTerm) if (scoreDiff !== 0) return scoreDiff } return a.title > b.title ? 1 : -1 }) const meta = { title: 'Supabase Features', description: 'From authentication to storage, everything you need to build and ship your next project.', } return ( <>