From 1fed5e1560acaecf4d334fa951fe646869a4ede3 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 15 Apr 2026 15:07:43 +0200 Subject: [PATCH] changelog v5 --- .../Changelog/ChangelogMonthTabExplorer.tsx | 245 ++++++-- .../Changelog/ChangelogV5Explorer.tsx | 571 ++++++++++++++++++ apps/www/pages/changelog-v2.tsx | 5 +- apps/www/pages/changelog-v3-modal.tsx | 23 +- apps/www/pages/changelog-v3.tsx | 8 +- apps/www/pages/changelog-v3/[number].tsx | 57 +- apps/www/pages/changelog-v4.tsx | 28 +- apps/www/pages/changelog-v5.tsx | 54 ++ apps/www/pages/changelog.tsx | 12 +- apps/www/public/rss.xml | 16 +- 10 files changed, 878 insertions(+), 141 deletions(-) create mode 100644 apps/www/components/Changelog/ChangelogV5Explorer.tsx create mode 100644 apps/www/pages/changelog-v5.tsx diff --git a/apps/www/components/Changelog/ChangelogMonthTabExplorer.tsx b/apps/www/components/Changelog/ChangelogMonthTabExplorer.tsx index 6a56ff8525f..bc4b91b18f8 100644 --- a/apps/www/components/Changelog/ChangelogMonthTabExplorer.tsx +++ b/apps/www/components/Changelog/ChangelogMonthTabExplorer.tsx @@ -1,14 +1,21 @@ 'use client' +import 'swiper/css' + import type { ChangelogTimelineIndexItem } from '~/lib/changelog-github' -import { githubChangelogLabelFilterUrl, githubLabelHex } from '~/lib/changelog.utils' +import { githubChangelogLabelFilterUrl } from '~/lib/changelog.utils' import mdxComponents from '~/lib/mdx/mdxComponents' import dayjs from 'dayjs' import { GitCommit } from 'lucide-react' import type { MDXRemoteSerializeResult } from 'next-mdx-remote' import { MDXRemote } from 'next-mdx-remote' import Link from 'next/link' +import { parseAsString, useQueryState } from 'nuqs' +import type { KeyboardEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Mousewheel } from 'swiper/modules' +import { Swiper, SwiperSlide, type SwiperClass } from 'swiper/react' +import { Badge } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import 'ui-patterns/ShimmeringLoader/index.css' @@ -47,18 +54,39 @@ type Props = { items: ChangelogTimelineIndexItem[] } +enum SwiperEdgeState { + START = 'START', + MIDDLE = 'MIDDLE', + END = 'END', +} + export function ChangelogMonthTabExplorer({ items }: Props) { const groups = useMemo(() => groupByMonthSorted(items), [items]) - const [activeKey, setActiveKey] = useState(null) - const tabRefs = useRef>(new Map()) + const [monthParam, setMonthParam] = useQueryState( + 'month', + parseAsString.withOptions({ shallow: true, history: 'push' }) + ) + const groupKeySet = useMemo(() => new Set(groups.map((g) => g.key)), [groups]) + const activeKey = useMemo(() => { + if (groups.length === 0) return null + if (monthParam && groupKeySet.has(monthParam)) return monthParam + return groups[0]!.key + }, [groupKeySet, groups, monthParam]) + const explorerTopRef = useRef(null) + const monthTabElRefs = useRef>(new Map()) + const [swiper, setSwiper] = useState(null) + const [swiperEdge, setSwiperEdge] = useState(SwiperEdgeState.START) const [docs, setDocs] = useState([]) const [loadingDocs, setLoadingDocs] = useState(false) - useEffect(() => { - if (activeKey === null && groups.length > 0) { - setActiveKey(groups[0]!.key) - } - }, [activeKey, groups]) + const activeIndex = useMemo( + () => + Math.max( + 0, + groups.findIndex((g) => g.key === activeKey) + ), + [activeKey, groups] + ) const activeEntries = useMemo(() => { if (!activeKey) return [] @@ -127,16 +155,98 @@ export function ChangelogMonthTabExplorer({ items }: Props) { } }, [activeKey, activeEntries]) - const setTabRef = useCallback((key: string, el: HTMLButtonElement | null) => { - if (el) tabRefs.current.set(key, el) - else tabRefs.current.delete(key) + const updateSwiperEdgeState = useCallback((s: SwiperClass) => { + if (s.isEnd) setSwiperEdge(SwiperEdgeState.END) + else if (s.isBeginning) setSwiperEdge(SwiperEdgeState.START) + else setSwiperEdge(SwiperEdgeState.MIDDLE) }, []) + const handleMonthTabClick = useCallback( + (key: string) => { + explorerTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + void setMonthParam(key) + }, + [setMonthParam] + ) + useEffect(() => { - if (!activeKey) return - const el = tabRefs.current.get(activeKey) - el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) - }, [activeKey]) + if (!swiper || !activeKey) return + if (swiper.activeIndex === activeIndex) return + swiper.slideTo(activeIndex, 260, false) + queueMicrotask(() => updateSwiperEdgeState(swiper)) + }, [activeIndex, activeKey, swiper, updateSwiperEdgeState]) + + const handleSwiperSlideChange = useCallback( + (s: SwiperClass) => { + updateSwiperEdgeState(s) + const key = groups[s.activeIndex]?.key + if (!key || key === activeKey) return + void setMonthParam(key) + }, + [activeKey, groups, setMonthParam, updateSwiperEdgeState] + ) + + const handleSwiperTap = useCallback( + (s: SwiperClass) => { + const i = s.clickedIndex + if (i < 0 || i >= groups.length) return + const group = groups[i] + if (!group) return + handleMonthTabClick(group.key) + }, + [groups, handleMonthTabClick] + ) + + const setMonthTabEl = useCallback((key: string, el: HTMLDivElement | null) => { + if (el) monthTabElRefs.current.set(key, el) + else monthTabElRefs.current.delete(key) + }, []) + + const focusMonthTab = useCallback((key: string) => { + queueMicrotask(() => monthTabElRefs.current.get(key)?.focus()) + }, []) + + const handleMonthTabKeyDown = useCallback( + (index: number, key: string, e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleMonthTabClick(key) + return + } + if (e.key === 'ArrowRight') { + e.preventDefault() + const next = groups[index + 1] + if (!next) return + void setMonthParam(next.key) + focusMonthTab(next.key) + return + } + if (e.key === 'ArrowLeft') { + e.preventDefault() + const prev = groups[index - 1] + if (!prev) return + void setMonthParam(prev.key) + focusMonthTab(prev.key) + return + } + if (e.key === 'Home') { + e.preventDefault() + const first = groups[0] + if (!first) return + void setMonthParam(first.key) + focusMonthTab(first.key) + return + } + if (e.key === 'End') { + e.preventDefault() + const last = groups[groups.length - 1] + if (!last) return + void setMonthParam(last.key) + focusMonthTab(last.key) + } + }, + [focusMonthTab, groups, handleMonthTabClick, setMonthParam] + ) const indexByNumber = useMemo(() => { const m = new Map() @@ -151,45 +261,79 @@ export function ChangelogMonthTabExplorer({ items }: Props) { } return ( -
-
-
-
+
+
+ { + setSwiper(instance) + updateSwiperEdgeState(instance) + }} + onSlideChange={handleSwiperSlideChange} + onTap={handleSwiperTap} + className="relative w-full overflow-hidden" role="tablist" aria-label="Changelog by month" > - {groups.map(({ key, label }) => { + {/*
*/} +
+ {groups.map(({ key, label }, index) => { const selected = key === activeKey return ( - + +
setMonthTabEl(key, el)} + role="tab" + tabIndex={selected ? 0 : -1} + aria-selected={selected} + onKeyDown={(e) => handleMonthTabKeyDown(index, key, e)} + className={cn( + 'select-none rounded-full px-3.5 py-1.5 text-sm whitespace-nowrap transition-colors outline-none focus-visible:ring-brand-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-100', + selected ? 'text-foreground' : 'text-foreground-lighter hover:text-foreground' + )} + > + {label} +
+
) })} -
+
- {loadingDocs && ( -
- -
- )} + {loadingDocs && } {!loadingDocs && activeEntries.length === 0 && (

Nothing in this month.

@@ -204,9 +348,9 @@ export function ChangelogMonthTabExplorer({ items }: Props) { return (
-
+
@@ -228,16 +372,11 @@ export function ChangelogMonthTabExplorer({ items }: Props) { href={githubChangelogLabelFilterUrl(label.name)} target="_blank" rel="noopener noreferrer" - className="inline-flex no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" + className="inline-flex no-underline focus-visible:ring-brand-default rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" > - + {label.name} - + ))}
diff --git a/apps/www/components/Changelog/ChangelogV5Explorer.tsx b/apps/www/components/Changelog/ChangelogV5Explorer.tsx new file mode 100644 index 00000000000..fa264f9f0b9 --- /dev/null +++ b/apps/www/components/Changelog/ChangelogV5Explorer.tsx @@ -0,0 +1,571 @@ +'use client' + +import type { ChangelogTimelineIndexItem } from '~/lib/changelog-github' +import { githubChangelogLabelFilterUrl } from '~/lib/changelog.utils' +import mdxComponents from '~/lib/mdx/mdxComponents' +import dayjs from 'dayjs' +import { Sun } from 'lucide-react' +import type { MDXRemoteSerializeResult } from 'next-mdx-remote' +import { MDXRemote } from 'next-mdx-remote' +import Link from 'next/link' +import { parseAsInteger, useQueryState } from 'nuqs' +import type { PointerEvent as ReactPointerEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Badge, cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import 'ui-patterns/ShimmeringLoader/index.css' + +/** Total strip height: axis, ticks above, labels below */ +const TIMELINE_HEIGHT = 112 +const AXIS_Y = 80 +const SIDE_PADDING = 32 +const MIN_TIMELINE_WIDTH = 1280 +/** Pixels per calendar day along the axis (larger = more separation, less overlap) */ +const DAY_WIDTH = 16 +/** Extra empty calendar days after the newest entry so the latest marker is not flush right */ +const TRAILING_EMPTY_DAYS = 18 +const TICK_DAY = 10 +const TICK_MONTH = 18 +const TICK_YEAR = 26 +/** Entry marker: dot center Y from top; stem runs from below dot to axis (keep close to axis for short stems) */ +const ENTRY_DOT_CENTER_Y = 48 +const ENTRY_DOT_DIAMETER_PX = 8 +const ENTRY_HIT_PAD_PX = 10 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +type FetchedDoc = { + number: number + title: string + url: string + created_at: string + source: MDXRemoteSerializeResult | null + error?: string +} + +type Props = { + items: ChangelogTimelineIndexItem[] +} + +export function ChangelogV5Explorer({ items }: Props) { + const [entryParam, setEntryParam] = useQueryState( + 'entry', + parseAsInteger.withOptions({ shallow: true, history: 'push' }) + ) + + const timelineItems = useMemo(() => { + return items + .filter((i) => !i.title.includes('[d]')) + .sort((a, b) => { + return dayjs(a.sortDate).valueOf() - dayjs(b.sortDate).valueOf() + }) + }, [items]) + + const indexByNumber = useMemo(() => { + const m = new Map() + for (const item of timelineItems) { + m.set(item.number, item) + } + return m + }, [timelineItems]) + + const range = useMemo(() => { + if (timelineItems.length === 0) return null + const firstItem = timelineItems[0] + const lastItem = timelineItems[timelineItems.length - 1] + if (!firstItem || !lastItem) return null + const first = dayjs(firstItem.sortDate).startOf('day') + const last = dayjs(lastItem.sortDate) + .startOf('day') + .add(TRAILING_EMPTY_DAYS, 'day') + .endOf('day') + return { start: first, end: last } + }, [timelineItems]) + + const rangeStartMs = range?.start.valueOf() ?? 0 + const rangeEndMs = range?.end.valueOf() ?? 1 + const rangeMs = Math.max(1, rangeEndMs - rangeStartMs) + + const dayCount = useMemo(() => { + if (!range) return 0 + return range.end.startOf('day').diff(range.start.startOf('day'), 'day') + 1 + }, [range]) + + const timelineWidth = useMemo(() => { + if (dayCount <= 0) return MIN_TIMELINE_WIDTH + return Math.max(MIN_TIMELINE_WIDTH, dayCount * DAY_WIDTH + SIDE_PADDING * 2) + }, [dayCount]) + + const scrollRef = useRef(null) + const [viewportScrollW, setViewportScrollW] = useState(0) + + useEffect(() => { + const el = scrollRef.current + if (!el || typeof ResizeObserver === 'undefined') return + const ro = new ResizeObserver(() => { + setViewportScrollW(el.clientWidth) + }) + ro.observe(el) + setViewportScrollW(el.clientWidth) + return () => ro.disconnect() + }, []) + + const canvasWidth = useMemo(() => { + const vw = viewportScrollW > 0 ? viewportScrollW : 0 + return Math.max(timelineWidth, vw || timelineWidth) + }, [timelineWidth, viewportScrollW]) + + const usableWidth = Math.max(1, canvasWidth - SIDE_PADDING * 2) + + const toX = useCallback( + (timestampMs: number) => { + return SIDE_PADDING + clamp((timestampMs - rangeStartMs) / rangeMs, 0, 1) * usableWidth + }, + [rangeStartMs, rangeMs, usableWidth] + ) + + const dayTicks = useMemo(() => { + if (!range || dayCount <= 0) return [] + const ticks: { day: dayjs.Dayjs; x: number; kind: 'day' | 'month' | 'year' }[] = [] + for (let i = 0; i < dayCount; i++) { + const d = range.start.startOf('day').add(i, 'day') + const isYearStart = d.month() === 0 && d.date() === 1 + const isMonthStart = d.date() === 1 + const kind: 'day' | 'month' | 'year' = isYearStart ? 'year' : isMonthStart ? 'month' : 'day' + ticks.push({ + day: d, + x: toX(d.add(12, 'hour').valueOf()), + kind, + }) + } + return ticks + }, [range, dayCount, toX]) + + const entryMarkers = useMemo(() => { + return timelineItems.map((item) => ({ + item, + x: toX(dayjs(item.sortDate).valueOf()), + })) + }, [timelineItems, toX]) + + const todayMarkX = useMemo(() => { + if (!range) return null + const today = dayjs().startOf('day') + if (today.isBefore(range.start, 'day') || today.isAfter(range.end, 'day')) { + return null + } + return toX(today.add(12, 'hour').valueOf()) + }, [range, toX]) + + const selectedNumber = useMemo(() => { + if (entryParam != null && indexByNumber.has(entryParam)) return entryParam + if (timelineItems.length === 0) return null + const last = timelineItems[timelineItems.length - 1] + return last ? last.number : null + }, [entryParam, indexByNumber, timelineItems]) + + const [doc, setDoc] = useState(null) + const [loadingDoc, setLoadingDoc] = useState(false) + + useEffect(() => { + if (selectedNumber == null) { + setDoc(null) + setLoadingDoc(false) + return + } + + let cancelled = false + setLoadingDoc(true) + setDoc(null) + + void (async () => { + try { + const res = await fetch(`/api/changelog-discussion/${selectedNumber}`) + if (cancelled) return + if (!res.ok) { + const preview = indexByNumber.get(selectedNumber) + setDoc({ + number: selectedNumber, + title: preview?.title ?? '', + url: preview?.url ?? '', + created_at: preview?.sortDate ?? '', + source: null, + error: res.status === 404 ? 'Not found' : 'Could not load', + }) + setLoadingDoc(false) + return + } + const data = (await res.json()) as { + title: string + url: string + created_at: string + source: MDXRemoteSerializeResult + } + if (cancelled) return + setDoc({ + number: selectedNumber, + title: data.title, + url: data.url, + created_at: data.created_at, + source: data.source, + }) + setLoadingDoc(false) + } catch { + if (cancelled) return + const preview = indexByNumber.get(selectedNumber) + setDoc({ + number: selectedNumber, + title: preview?.title ?? '', + url: preview?.url ?? '', + created_at: preview?.sortDate ?? '', + source: null, + error: 'Network error', + }) + setLoadingDoc(false) + } + })() + + return () => { + cancelled = true + } + }, [selectedNumber, indexByNumber]) + + const dragStartXRef = useRef(0) + const dragStartScrollLeftRef = useRef(0) + const [isDragging, setIsDragging] = useState(false) + + const selectedItemX = useMemo(() => { + if (selectedNumber == null) return null + const item = indexByNumber.get(selectedNumber) + if (!item) return null + return toX(dayjs(item.sortDate).valueOf()) + }, [selectedNumber, indexByNumber, toX]) + + useEffect(() => { + if (selectedItemX == null) return + const el = scrollRef.current + if (!el) return + const pad = 48 + const left = el.scrollLeft + const right = left + el.clientWidth + if (selectedItemX < left + pad || selectedItemX > right - pad) { + const maxScroll = Math.max(0, canvasWidth - el.clientWidth) + el.scrollLeft = clamp(selectedItemX - el.clientWidth / 2, 0, maxScroll) + } + }, [selectedItemX, canvasWidth]) + + const handleSelectEntry = useCallback( + (n: number) => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + void setEntryParam(n) + }, + [setEntryParam] + ) + + const handlePointerDownStrip = useCallback((event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest('[data-changelog-v5-entry]')) { + return + } + const viewport = scrollRef.current + if (!viewport) return + dragStartXRef.current = event.clientX + dragStartScrollLeftRef.current = viewport.scrollLeft + setIsDragging(true) + event.currentTarget.setPointerCapture(event.pointerId) + }, []) + + const handlePointerMoveStrip = useCallback( + (event: ReactPointerEvent) => { + if (!isDragging) return + const viewport = scrollRef.current + if (!viewport) return + const deltaX = event.clientX - dragStartXRef.current + viewport.scrollLeft = dragStartScrollLeftRef.current - deltaX + }, + [isDragging] + ) + + const endDrag = useCallback( + (event: ReactPointerEvent) => { + if (!isDragging) return + setIsDragging(false) + try { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + } catch { + /* ignore */ + } + }, + [isDragging] + ) + + const labels = indexByNumber.get(selectedNumber ?? 0)?.labels ?? [] + + if (timelineItems.length === 0 || !range) { + return

No changelog entries loaded.

+ } + + return ( + <> +
+

Changelog

+

New updates and product improvements

+
+ +
+
+ {loadingDoc && } + + {!loadingDoc && doc && ( +
+
+ {doc.title && ( + +

{doc.title}

+ + )} +

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

+ {labels.length > 0 && ( +
+ {labels.map((label) => ( + + + {label.name} + + + ))} +
+ )} + {doc.error &&

{doc.error}

} +
+ {doc.source ? ( +
+ +
+ ) : ( + !doc.error &&

No content.

+ )} +
+ )} +
+ +
+ +
+
+ +
{ + if (isDragging) endDrag(e) + }} + onPointerMove={handlePointerMoveStrip} + onPointerUp={endDrag} + onPointerCancel={endDrag} + > + + + {todayMarkX != null ? ( +
+ + Today + + +
+ ) : null} + + {entryMarkers.map(({ item, x }) => { + const active = item.number === selectedNumber + const stemTop = ENTRY_DOT_CENTER_Y + ENTRY_DOT_DIAMETER_PX / 2 + const stemHeight = Math.max(0, AXIS_Y - stemTop) + const hitW = ENTRY_DOT_DIAMETER_PX + ENTRY_HIT_PAD_PX * 2 + const dotTriggerTop = + ENTRY_DOT_CENTER_Y - ENTRY_DOT_DIAMETER_PX / 2 - ENTRY_HIT_PAD_PX + const dotTriggerHeight = ENTRY_DOT_DIAMETER_PX + ENTRY_HIT_PAD_PX * 2 + return ( + +
+ + + +
+ + {item.title} + +
+ ) + })} + + {dayTicks + .filter((t) => t.kind !== 'day') + .map(({ day, x, kind }) => ( +
+ {kind === 'year' ? day.format('YYYY') : day.format('MMM')} +
+ ))} +
+
+
+
+
+ + ) +} diff --git a/apps/www/pages/changelog-v2.tsx b/apps/www/pages/changelog-v2.tsx index a9195977f88..7d52789186b 100644 --- a/apps/www/pages/changelog-v2.tsx +++ b/apps/www/pages/changelog-v2.tsx @@ -385,10 +385,7 @@ function ChangelogV2Page({ changelogIndex, changelog, pageInfo, restPage }: Chan >

Changelog

-

- New updates and product improvements{' '} - (v2 — full index) -

+

New updates and product improvements

Changelog

-

- Same index as v3, but entries open in a modal (experiment). Prefer full pages?{' '} - - Use the list + detail layout - - . Share or bookmark a specific entry with the GitHub discussion number in the URL, - e.g.{' '} - - ?discussion=123 - - . -

+

New updates and product improvements

@@ -167,16 +156,6 @@ function ChangelogV3ModalContent({ changelogIndex }: PageProps) { {dayjs(displayDateIso).format('MMM D, YYYY')}

)} - {displayUrl && ( - - Open on GitHub - - )} {error && {error}}
diff --git a/apps/www/pages/changelog-v3.tsx b/apps/www/pages/changelog-v3.tsx index 73a9ab0822d..a75e8491924 100644 --- a/apps/www/pages/changelog-v3.tsx +++ b/apps/www/pages/changelog-v3.tsx @@ -40,13 +40,7 @@ function ChangelogV3Page({ changelogIndex }: PageProps) {

Changelog

-

- List-first layout (experiment). Open an entry on this site, or try the{' '} - - modal variant - - . -

+

New updates and product improvements

diff --git a/apps/www/pages/changelog-v3/[number].tsx b/apps/www/pages/changelog-v3/[number].tsx index 87a59df1196..f641547c7b2 100644 --- a/apps/www/pages/changelog-v3/[number].tsx +++ b/apps/www/pages/changelog-v3/[number].tsx @@ -11,6 +11,7 @@ import { discussionDisplayDate } from '~/lib/changelog.utils' import mdxComponents from '~/lib/mdx/mdxComponents' import { mdxSerialize } from '~/lib/mdx/mdxSerialize' import dayjs from 'dayjs' +import { ArrowUpRightIcon, Github } from 'lucide-react' import { GetServerSideProps } from 'next' import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote' import { NextSeo } from 'next-seo' @@ -111,33 +112,32 @@ function ChangelogV3DetailPage({ }} /> -
+

{title}

-

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

- - View discussion on GitHub - +
+

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

+ + View discussion on GitHub + + +
@@ -145,22 +145,23 @@ function ChangelogV3DetailPage({
- {prevNumber != null ? ( - - Older - - ) : ( - - )} {nextNumber != null ? ( - Newer + Newer + + ) : ( + + )} + {prevNumber != null ? ( + + Older + ) : ( diff --git a/apps/www/pages/changelog-v4.tsx b/apps/www/pages/changelog-v4.tsx index 807cb1385a3..73fe212b947 100644 --- a/apps/www/pages/changelog-v4.tsx +++ b/apps/www/pages/changelog-v4.tsx @@ -6,6 +6,7 @@ import { getChangelogTimelineSortedIndex } from '~/lib/changelog-github' import type { GetServerSideProps } from 'next' import { NextSeo } from 'next-seo' import Link from 'next/link' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' type PageProps = { changelogIndex: ChangelogTimelineIndexItem[] @@ -35,25 +36,16 @@ export default function ChangelogV4MonthTabsPage({ changelogIndex }: PageProps) }} /> -
-
-

Changelog

-

- Month tabs experiment — strip stays sticky while you scroll. Full entries load for - the selected month (same MDX as the main changelog).{' '} - - v3 timeline - - {' · '} - - v3 modal - - . -

-
+ +
+
+

Changelog

+

New updates and product improvements

+
- -
+ +
+
diff --git a/apps/www/pages/changelog-v5.tsx b/apps/www/pages/changelog-v5.tsx new file mode 100644 index 00000000000..dc595b5e616 --- /dev/null +++ b/apps/www/pages/changelog-v5.tsx @@ -0,0 +1,54 @@ +import { ChangelogV5Explorer } from '~/components/Changelog/ChangelogV5Explorer' +import CTABanner from '~/components/CTABanner' +import DefaultLayout from '~/components/Layouts/Default' +import type { ChangelogTimelineIndexItem } from '~/lib/changelog-github' +import { getChangelogTimelineSortedIndex } from '~/lib/changelog-github' +import type { GetServerSideProps } from 'next' +import { NextSeo } from 'next-seo' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' + +type PageProps = { + changelogIndex: ChangelogTimelineIndexItem[] +} + +export const getServerSideProps: GetServerSideProps = async ({ res }) => { + res.setHeader('Cache-Control', 'public, max-age=900, stale-while-revalidate=900') + try { + const changelogIndex = await getChangelogTimelineSortedIndex() + return { props: { changelogIndex } } + } catch (e) { + console.error(e) + return { props: { changelogIndex: [] } } + } +} + +export default function ChangelogV5TimelinePage({ changelogIndex }: PageProps) { + return ( + <> + + + +
+ +
+
+ +
+ + ) +} diff --git a/apps/www/pages/changelog.tsx b/apps/www/pages/changelog.tsx index be94ffb7dce..5c724c4b0d5 100644 --- a/apps/www/pages/changelog.tsx +++ b/apps/www/pages/changelog.tsx @@ -18,6 +18,7 @@ import { GetServerSideProps } from 'next' import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote' import { NextSeo } from 'next-seo' import Link from 'next/link' +import { Badge } from 'ui' export type Discussion = { id: string @@ -349,16 +350,11 @@ function ChangelogPage({ changelog, pageInfo, restPage }: ChangelogPageProps) { href={githubChangelogLabelFilterUrl(label.name)} target="_blank" rel="noopener noreferrer" - className="inline-flex no-underline focus-visible:ring-brand-default rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" + 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" > - + {label.name} - + ))}
diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 7ccaf09352d..0dfcb71b3df 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -4,9 +4,23 @@ https://supabase.com Latest news from Supabase en - Thu, 02 Apr 2026 00:00:00 -0700 + Thu, 09 Apr 2026 00:00:00 -0700 + https://supabase.com/blog/supabase-agent-skills + AI Agents Know About Supabase. They Don't Always Use It Right. + https://supabase.com/blog/supabase-agent-skills + Introducing Supabase Agent Skills: an open-source set of instructions that teach AI coding agents how to build on Supabase correctly. + Thu, 09 Apr 2026 00:00:00 -0700 + + + https://supabase.com/blog/custom-oauth-oidc-providers + Custom OIDC Providers for Supabase Auth + https://supabase.com/blog/custom-oauth-oidc-providers + Connect any OpenID Connect identity provider to your Supabase project: GitHub Enterprise, regional providers, and more. + Wed, 08 Apr 2026 00:00:00 -0700 + + https://supabase.com/blog/100000-github-stars 100,000 GitHub stars https://supabase.com/blog/100000-github-stars