diff --git a/apps/www/components/Changelog/ChangelogV3TimelineList.tsx b/apps/www/components/Changelog/ChangelogV3TimelineList.tsx index a2cd113a628..e62c1de650e 100644 --- a/apps/www/components/Changelog/ChangelogV3TimelineList.tsx +++ b/apps/www/components/Changelog/ChangelogV3TimelineList.tsx @@ -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} e.stopPropagation()} /> @@ -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 ( +
+ {items.map((item) => ( + + ))} +
+ ) +} + 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) { >

{year}

diff --git a/apps/www/pages/changelog-progressive.tsx b/apps/www/pages/changelog-progressive.tsx index 7b8c79b64db..25363894795 100644 --- a/apps/www/pages/changelog-progressive.tsx +++ b/apps/www/pages/changelog-progressive.tsx @@ -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 = async ({ res }) => { @@ -64,6 +91,7 @@ export const getServerSideProps: GetServerSideProps = 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 = 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 = 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(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) {

New updates and product improvements

- - - - -
-
- {featured.map((entry) => ( -
+
- ))} -
- - {restIndex.length > 0 && ( -
- -
- )} -
-
-
- - - -
+ {filterPanelOpen ? 'Hide filters' : 'Filter changelog'} + +
+ + {filterPanelOpen && ( +
+
+
+ + setFilterSearch(e.target.value)} + /> + {(filterSearch.trim().length > 0 || selectedTags.size > 0) && ( + + )} +
+
+
+

Filter by tags

+
+ {CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => { + const on = selectedTags.has(slug) + return ( + + ) + })} +
+
+
+ )} + + {filteredIndex != null ? ( +
+ {filteredIndex.length === 0 ? ( +

No entries match your filters.

+ ) : ( + <> +

+ {filteredIndex.length} {filteredIndex.length === 1 ? 'result' : 'results'} +

+ + + )} +
+ ) : ( +
+
+ {featured.map((entry) => ( +
+
+
+
+ +
+
+ {entry.title && ( + +

{entry.title}

+ + )} +

+ {dayjs(entry.created_at).format('MMM D, YYYY')} +

+ {entry.labels && entry.labels.length > 0 && ( +
+ {entry.labels.map((label) => ( + + + {label.name} + + + ))} +
+ )} +
+
+
+
+
+ +
+
+
+ ))} +
+ + {restIndex.length > 0 && ( +
+ +
+ )} +
+
+
+ + + +
+
+
+
+ )}