changelog filters

This commit is contained in:
Francesco Sansalvadore committed 2026-04-21 14:30:53 +02:00
1 parent 953a3edf12
commit 23c5e62ab0
2 files changed
+294 -103

No files matched your search

@@ -1,5 +1,5 @@
import type { ChangelogLabel, ChangelogTimelineIndexItem } from '~/lib/changelog-github'
import { githubChangelogLabelFilterUrl, githubLabelHex } from '~/lib/changelog.utils'
import { githubChangelogLabelFilterUrl } from '~/lib/changelog.utils'
import dayjs from 'dayjs'
import { GitCommit } from 'lucide-react'
import Link from 'next/link'
@@ -24,7 +24,6 @@ function LabelBadges({
}: {
labels: ChangelogLabel[]
onBadgeClick?: (e: MouseEvent) => void
/** Minimal chip for dense list rows */
tiny?: boolean
}) {
if (labels.length === 0) return null
@@ -63,13 +62,18 @@ function TimelineRow({
mode,
href,
onSelect,
dateFormat = 'month-day',
}: {
item: ChangelogTimelineIndexItem
mode: 'link' | 'action'
href?: string
onSelect?: (item: ChangelogTimelineIndexItem) => void
dateFormat?: 'month-day' | 'full'
}) {
const dateNoYear = dayjs(item.sortDate).format('MMM D')
const dateLabel =
dateFormat === 'full'
? dayjs(item.sortDate).format('MMM D, YYYY')
: dayjs(item.sortDate).format('MMM D')
const labels = item.labels ?? []
const titleBlock =
@@ -87,7 +91,7 @@ function TimelineRow({
dateTime={item.sortDate}
className="text-foreground-lighter group-hover:text-foreground-light text-xs tracking-normal"
>
{dateNoYear}
{dateLabel}
</time>
<LabelBadges labels={labels} onBadgeClick={(e) => e.stopPropagation()} />
</div>
@@ -118,12 +122,34 @@ function TimelineRow({
)
}
export function ChangelogV3TimelineFlatList(props: {
items: ChangelogTimelineIndexItem[]
mode: 'action'
onSelect: (item: ChangelogTimelineIndexItem) => void
showFullDate?: boolean
}) {
const { items, onSelect, showFullDate } = props
const dateFormat = showFullDate ? 'full' : 'month-day'
return (
<div className="min-w-0 [&>*:last-child]:border-b-0" role="list">
{items.map((item) => (
<TimelineRow
key={item.number}
item={item}
mode="action"
onSelect={onSelect}
dateFormat={dateFormat}
/>
))}
</div>
)
}
type Props =
| {
items: ChangelogTimelineIndexItem[]
mode: 'link'
hrefFor: (item: ChangelogTimelineIndexItem) => string
/** When true, no left border / offset — use when an ancestor already draws the timeline line. */
omitOuterTimelineBorder?: boolean
}
| {
@@ -152,7 +178,7 @@ export function ChangelogV3TimelineList(props: Props) {
>
<h2
id={`changelog-year-${year}`}
className="border-default bg-default text-foreground-light sticky top-28 z-20 border-b py-2 pl-0 font-mono text-sm tracking-wide lg:hidden"
className="border-default bg-default text-foreground-light sticky top-[65px] z-20 border-b py-2 pl-0 font-mono text-sm tracking-wide lg:hidden"
>
{year}
</h2>
+262 -97
View File
@@ -1,5 +1,8 @@
import { ChangelogRssButton } from '~/components/Changelog/ChangelogRssButton'
import { ChangelogV3TimelineList } from '~/components/Changelog/ChangelogV3TimelineList'
import {
ChangelogV3TimelineFlatList,
ChangelogV3TimelineList,
} from '~/components/Changelog/ChangelogV3TimelineList'
import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import {
@@ -13,7 +16,7 @@ import { discussionDisplayDate, githubChangelogLabelFilterUrl } from '~/lib/chan
import mdxComponents from '~/lib/mdx/mdxComponents'
import { mdxSerialize } from '~/lib/mdx/mdxSerialize'
import dayjs from 'dayjs'
import { GitCommit } from 'lucide-react'
import { GitCommit, ListFilter, X } from 'lucide-react'
import type { GetServerSideProps } from 'next'
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { MDXRemote } from 'next-mdx-remote'
@@ -24,18 +27,40 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages'
import { useEffect, useMemo, useState } from 'react'
import {
Badge,
Button,
cn,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
IconYCombinator,
Input,
Input_Shadcn_,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import 'ui-patterns/ShimmeringLoader/index.css'
const FEATURED_COUNT = 10
const FEATURED_COUNT = 5
/** GitHub changelog label slugs used for product filters. */
const CHANGELOG_PRODUCT_TAGS = [
{ slug: 'storage', label: 'Storage' },
{ slug: 'database', label: 'Database' },
{ slug: 'realtime', label: 'Realtime' },
{ slug: 'edge-functions', label: 'Edge Functions' },
{ slug: 'auth', label: 'Auth' },
{ slug: 'sdk', label: 'SDK' },
{ slug: 'self-hosted', label: 'Self-hosted' },
{ slug: 'vector', label: 'Vector' },
{ slug: 'billing', label: 'Billing' },
{ slug: 'breaking-change', label: 'Breaking Change' },
{ slug: 'cli', label: 'CLI' },
{ slug: 'infra', label: 'Infra' },
{ slug: 'dashboard', label: 'Dashboard' },
{ slug: 'docs', label: 'Docs' },
] as const
type FeaturedEntry = {
number: number
@@ -56,6 +81,8 @@ type ModalPayload = {
type PageProps = {
featured: FeaturedEntry[]
restIndex: ChangelogTimelineIndexItem[]
/** Full sorted index (visible only), for client-side filtering. */
allIndex: ChangelogTimelineIndexItem[]
}
export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res }) => {
@@ -64,6 +91,7 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res })
try {
const changelogIndex = await getChangelogTimelineSortedIndex()
const visible = changelogIndex.filter((item) => !item.title.includes('[d]'))
const allIndex = visible
const firstMeta = visible.slice(0, FEATURED_COUNT)
const restIndex = visible.slice(FEATURED_COUNT)
@@ -80,14 +108,15 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res })
)
if (!discussion) return null
const source = await mdxSerialize(discussion.body)
const created_at = discussionDisplayDate({
title: discussion.title,
createdAt: discussion.createdAt,
})
const created_at =
discussionDisplayDate({
title: discussion.title,
createdAt: discussion.createdAt,
}) ?? discussion.createdAt
return {
number: meta.number,
title: discussion.title,
url: discussion.url,
url: discussion.url ?? '',
created_at,
source,
labels: meta.labels,
@@ -100,10 +129,10 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({ res })
)
).filter((e): e is FeaturedEntry => e != null)
return { props: { featured, restIndex } }
return { props: { featured, restIndex, allIndex } }
} catch (e) {
console.error(e)
return { props: { featured: [], restIndex: [] } }
return { props: { featured: [], restIndex: [], allIndex: [] } }
}
}
@@ -115,7 +144,26 @@ export default function ChangelogProgressivePage(props: PageProps) {
)
}
function ChangelogProgressiveContent({ featured, restIndex }: PageProps) {
function itemMatchesSearch(item: ChangelogTimelineIndexItem, q: string) {
const n = q.trim().toLowerCase()
if (!n) return true
if (item.title.toLowerCase().includes(n)) return true
return item.labels.some((l) => l.name.toLowerCase().includes(n))
}
function itemMatchesSelectedTags(
item: ChangelogTimelineIndexItem,
selected: Set<(typeof CHANGELOG_PRODUCT_TAGS)[number]['slug']>
) {
if (selected.size === 0) return true
const labelNames = new Set(item.labels.map((l) => l.name.toLowerCase()))
for (const slug of selected) {
if (labelNames.has(slug.toLowerCase())) return true
}
return false
}
function ChangelogProgressiveContent({ featured, restIndex, allIndex }: PageProps) {
const [discussion, setDiscussion] = useQueryState(
'discussion',
parseAsInteger.withOptions({ shallow: true, history: 'push' })
@@ -125,6 +173,36 @@ function ChangelogProgressiveContent({ featured, restIndex }: PageProps) {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [filterPanelOpen, setFilterPanelOpen] = useState(false)
const [filterSearch, setFilterSearch] = useState('')
const [selectedTags, setSelectedTags] = useState<
Set<(typeof CHANGELOG_PRODUCT_TAGS)[number]['slug']>
>(() => new Set())
const filteredIndex = useMemo(() => {
const q = filterSearch
const hasSearch = q.trim().length > 0
const hasTags = selectedTags.size > 0
if (!hasSearch && !hasTags) return null
return allIndex
.filter((item) => itemMatchesSearch(item, q) && itemMatchesSelectedTags(item, selectedTags))
.sort((a, b) => dayjs(b.sortDate).diff(dayjs(a.sortDate)))
}, [allIndex, filterSearch, selectedTags])
const toggleProductTag = (slug: (typeof CHANGELOG_PRODUCT_TAGS)[number]['slug']) => {
setSelectedTags((prev) => {
const next = new Set(prev)
if (next.has(slug)) next.delete(slug)
else next.add(slug)
return next
})
}
const clearFilters = () => {
setFilterSearch('')
setSelectedTags(new Set())
}
const preview = useMemo(() => {
if (discussion == null) return null
const fromFeatured = featured.find((e) => e.number === discussion)
@@ -135,16 +213,16 @@ function ChangelogProgressiveContent({ featured, restIndex }: PageProps) {
dateIso: fromFeatured.created_at,
}
}
const fromRest = restIndex.find((e) => e.number === discussion)
if (fromRest) {
const fromIndex = allIndex.find((e) => e.number === discussion)
if (fromIndex) {
return {
title: fromRest.title,
url: fromRest.url,
dateIso: fromRest.sortDate,
title: fromIndex.title,
url: fromIndex.url,
dateIso: fromIndex.sortDate,
}
}
return null
}, [discussion, featured, restIndex])
}, [discussion, featured, allIndex])
useEffect(() => {
if (discussion == null) {
@@ -231,90 +309,177 @@ function ChangelogProgressiveContent({ featured, restIndex }: PageProps) {
<p className="text-foreground-lighter text-lg">
New updates and product improvements
</p>
<ChangelogRssButton />
</div>
</div>
<div
className="border-muted relative lg:ml-2 lg:border-l lg:pl-8"
aria-label="Changelog timeline"
>
<div className="grid">
{featured.map((entry) => (
<div
key={entry.number}
id={entry.number.toString()}
className="grid pb-12 lg:grid-cols-12 lg:gap-8 lg:pb-36 scroll-mt-32"
<div className="flex flex-wrap items-center gap-1">
<Button
type={filterPanelOpen ? 'default' : 'text'}
size="tiny"
className="shrink-0"
aria-expanded={filterPanelOpen}
aria-controls="changelog-progressive-filters"
icon={
filterPanelOpen ? (
<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />
) : (
<ListFilter className="h-4 w-4" strokeWidth={1.5} aria-hidden />
)
}
onClick={() => setFilterPanelOpen((o) => !o)}
>
<div className="col-span-12 -ml-[31px] mb-8 lg:mb-0 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-32 lg:pt-32">
<div className="flex w-full items-baseline border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<div className="hidden lg:flex bg-border border-muted text-foreground-lighter -ml-2.5 h-5 w-5 items-center justify-center rounded border drop-shadow-sm">
<GitCommit size={14} strokeWidth={1.5} />
</div>
<div className="flex w-full flex-col gap-1">
{entry.title && (
<Link href={entry.url}>
<h3 className="text-foreground text-lg">{entry.title}</h3>
</Link>
)}
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(entry.created_at).format('MMM D, YYYY')}
</p>
{entry.labels && entry.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5">
{entry.labels.map((label) => (
<a
key={`${entry.number}-${label.name}`}
href={githubChangelogLabelFilterUrl(label.name)}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Badge className="group-hover:text-foreground-light text-foreground-lighter group-hover:border-foreground-muted px-1.5 py-px text-[11px] lowercase">
{label.name}
</Badge>
</a>
))}
</div>
)}
</div>
</div>
</div>
<div className="col-span-8 lg:max-w-[calc(100vw-80px)]">
<article className="prose prose-docs max-w-none [overflow-wrap:break-word]">
<MDXRemote {...entry.source} components={mdxComponents('blog')} />
</article>
</div>
</div>
))}
</div>
{restIndex.length > 0 && (
<section aria-label="Earlier changelog entries" className="lg:pb-28">
<ChangelogV3TimelineList
items={restIndex}
mode="action"
onSelect={handleSelectFromList}
omitOuterTimelineBorder
/>
</section>
)}
<div className="hidden lg:grid">
<div className="col-span-12 -ml-8 mb-8 lg:mb-0 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-32 lg:pt-32">
<div className="flex w-full items-baseline border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<Link
href="https://www.ycombinator.com/companies/supabase"
target="_blank"
rel="noopener noreferrer"
className="hidden lg:flex -ml-2 text-foreground-lighter hover:text-foreground"
title="YCombinator —  Summer 2020"
>
<IconYCombinator size={16} className="text-current" />
</Link>
</div>
{filterPanelOpen ? 'Hide filters' : 'Filter changelog'}
</Button>
<ChangelogRssButton />
</div>
</div>
</div>
{filterPanelOpen && (
<div id="changelog-progressive-filters" className="flex flex-col gap-2 -mt-4 sm:mx-0">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative min-w-0 flex-1">
<label htmlFor="changelog-filter-search" className="sr-only">
Search changelog
</label>
<Input_Shadcn_
id="changelog-filter-search"
size="small"
placeholder="Search changelog..."
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
/>
{(filterSearch.trim().length > 0 || selectedTags.size > 0) && (
<Button
type="outline"
size="tiny"
className="absolute inset-1 my-auto left-auto shrink-0"
onClick={clearFilters}
icon={<X className="h-4 w-4" strokeWidth={1.5} aria-hidden />}
>
Clear filters
</Button>
)}
</div>
</div>
<div>
<p className="sr-only">Filter by tags</p>
<div className="flex flex-wrap gap-1.5">
{CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => {
const on = selectedTags.has(slug)
return (
<button key={slug} type="button" onClick={() => toggleProductTag(slug)}>
<Badge
variant={on ? 'success' : 'default'}
className={cn(!on && 'hover:text-foreground')}
>
{label}
</Badge>
</button>
)
})}
</div>
</div>
</div>
)}
{filteredIndex != null ? (
<section aria-label="Filtered changelog entries" className="min-w-0">
{filteredIndex.length === 0 ? (
<p className="text-foreground-lighter text-sm">No entries match your filters.</p>
) : (
<>
<p className="text-foreground-lighter mb-3 text-sm">
{filteredIndex.length} {filteredIndex.length === 1 ? 'result' : 'results'}
</p>
<ChangelogV3TimelineFlatList
items={filteredIndex}
mode="action"
onSelect={handleSelectFromList}
showFullDate
/>
</>
)}
</section>
) : (
<div
className="border-muted relative lg:ml-2 lg:border-l lg:pl-8"
aria-label="Changelog timeline"
>
<div className="grid">
{featured.map((entry) => (
<div
key={entry.number}
id={entry.number.toString()}
className="grid pb-12 lg:grid-cols-12 lg:gap-8 lg:pb-36 scroll-mt-32"
>
<div className="col-span-12 lg:-ml-[31px] mb-8 lg:mb-0 self-start lg:sticky lg:top-32 lg:col-span-4">
<div className="flex w-full items-baseline border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<div className="hidden lg:flex bg-border border-muted text-foreground-lighter -ml-2.5 h-5 w-5 items-center justify-center rounded border drop-shadow-sm">
<GitCommit size={14} strokeWidth={1.5} />
</div>
<div className="flex w-full flex-col gap-1">
{entry.title && (
<Link href={entry.url}>
<h3 className="text-foreground text-lg">{entry.title}</h3>
</Link>
)}
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(entry.created_at).format('MMM D, YYYY')}
</p>
{entry.labels && entry.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5">
{entry.labels.map((label) => (
<a
key={`${entry.number}-${label.name}`}
href={githubChangelogLabelFilterUrl(label.name)}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Badge className="group-hover:text-foreground-light text-foreground-lighter group-hover:border-foreground-muted px-1.5 py-px text-[11px] lowercase">
{label.name}
</Badge>
</a>
))}
</div>
)}
</div>
</div>
</div>
<div className="col-span-8 lg:max-w-[calc(100vw-80px)]">
<article className="prose prose-docs max-w-none [overflow-wrap:break-word]">
<MDXRemote {...entry.source} components={mdxComponents('blog')} />
</article>
</div>
</div>
))}
</div>
{restIndex.length > 0 && (
<section aria-label="Earlier changelog entries" className="lg:pb-28">
<ChangelogV3TimelineList
items={restIndex}
mode="action"
onSelect={handleSelectFromList}
omitOuterTimelineBorder
/>
</section>
)}
<div className="hidden lg:grid">
<div className="col-span-12 -ml-8 mb-8 lg:mb-0 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-32 lg:pt-32">
<div className="flex w-full items-baseline border-b pb-4 lg:gap-4 lg:border-none lg:pb-0">
<Link
href="https://www.ycombinator.com/companies/supabase"
target="_blank"
rel="noopener noreferrer"
className="hidden lg:flex -ml-2 text-foreground-lighter hover:text-foreground"
title="YCombinator —  Summer 2020"
>
<IconYCombinator size={16} className="text-current" />
</Link>
</div>
</div>
</div>
</div>
)}
</div>
<CTABanner />
</DefaultLayout>