changelog v5

This commit is contained in:
Francesco Sansalvadore committed 2026-04-15 15:07:43 +02:00
1 parent caefbc32fd
commit 1fed5e1560
10 files changed
+878 -141

No files matched your search

@@ -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<string | null>(null)
const tabRefs = useRef<Map<string, HTMLButtonElement>>(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<HTMLDivElement>(null)
const monthTabElRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [swiper, setSwiper] = useState<SwiperClass | null>(null)
const [swiperEdge, setSwiperEdge] = useState<SwiperEdgeState>(SwiperEdgeState.START)
const [docs, setDocs] = useState<FetchedDoc[]>([])
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<HTMLDivElement>) => {
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<number, ChangelogTimelineIndexItem>()
@@ -151,45 +261,79 @@ export function ChangelogMonthTabExplorer({ items }: Props) {
}
return (
<div className="flex flex-col">
<div className="border-default bg-default/90 supports-[backdrop-filter]:bg-default/90 sticky top-[65px] z-30 py-3 backdrop-blur-md">
<div className="border-default overflow-hidden rounded-full border bg-surface-100 shadow-sm">
<div
className="flex flex-nowrap gap-1.5 overflow-x-auto overflow-y-hidden px-2 py-2 sm:gap-2 sm:px-3 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
<div ref={explorerTopRef} className="flex flex-col scroll-mt-[80px]">
<div className="w-full border-default sticky flex top-[65px] z-30 py-3">
<div className="border-default relative flex justify-center overflow-hidden px-2 py-2 rounded-full border bg-surface-100/80 shadow-sm supports-[backdrop-filter]:bg-surface-100/80 backdrop-blur-md lg:mx-[-2rem] lg:!w-[calc(100%+4rem)] [&_.swiper-slide]:!w-auto">
<Swiper
modules={[Mousewheel]}
mousewheel={{
enabled: true,
sensitivity: 0.85,
releaseOnEdges: true,
}}
initialSlide={activeIndex}
spaceBetween={8}
slidesPerView="auto"
grabCursor
watchOverflow
threshold={2}
touchRatio={1}
shortSwipes
longSwipesRatio={0.22}
simulateTouch
preventClicks
preventClicksPropagation
updateOnWindowResize
onSwiper={(instance) => {
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 }) => {
{/* <div
className={cn(
'pointer-events-none absolute inset-y-0 left-0 z-20 w-20 h-full bg-gradient-to-r from-background-surface-100 via-background-surface-100/80 to-transparent opacity-0 transition-opacity supports-[backdrop-filter]:from-background-surface-100/90',
swiperEdge !== SwiperEdgeState.START && 'opacity-100'
)}
aria-hidden
/> */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 z-20 w-20 h-full bg-gradient-to-l from-background-surface-100 via-background-surface-100/80 to-transparent opacity-0 transition-opacity supports-[backdrop-filter]:from-background-surface-100/90',
swiperEdge !== SwiperEdgeState.END && 'opacity-100'
)}
aria-hidden
/>
{groups.map(({ key, label }, index) => {
const selected = key === activeKey
return (
<button
key={key}
type="button"
role="tab"
aria-selected={selected}
ref={(el) => setTabRef(key, el)}
onClick={() => setActiveKey(key)}
className={cn(
'px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
selected
? 'text-foreground'
: 'text-foreground-lighter hover:text-foreground shrink-0'
)}
>
{label}
</button>
<SwiperSlide key={key} className="!w-auto shrink-0">
<div
ref={(el) => 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}
</div>
</SwiperSlide>
)
})}
</div>
</Swiper>
</div>
</div>
<div className="min-h-[40vh] pt-6">
{loadingDocs && (
<div className="border-muted rounded-lg border p-6">
<GenericSkeletonLoader />
</div>
)}
{loadingDocs && <GenericSkeletonLoader />}
{!loadingDocs && activeEntries.length === 0 && (
<p className="text-foreground-lighter p-6 text-sm">Nothing in this month.</p>
@@ -204,9 +348,9 @@ export function ChangelogMonthTabExplorer({ items }: Props) {
return (
<div
key={doc.number}
className="border-muted grid pb-12 lg:grid-cols-12 lg:gap-8 lg:border-l lg:pb-36"
className="border-muted grid pb-12 last:pb-0 lg:grid-cols-12 lg:gap-8 lg:border-l lg:pb-36"
>
<div className="col-span-12 mb-8 self-start lg:sticky lg:top-0 lg:col-span-4 lg:-mt-32 lg:pt-32">
<div className="col-span-12 mb-8 self-start lg:sticky lg:top-4 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} />
@@ -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"
>
<span
className="border-default bg-surface-200 text-foreground-light rounded-md border border-l-[3px] px-1.5 py-px text-[11px] font-medium leading-tight"
style={{
borderLeftColor: `#${githubLabelHex(label.color)}`,
}}
>
<Badge className="px-1.5 py-px lowercase text-[11px] leading-tight">
{label.name}
</span>
</Badge>
</a>
))}
</div>
@@ -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<number, ChangelogTimelineIndexItem>()
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<HTMLDivElement>(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<FetchedDoc | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 <p className="text-foreground-lighter text-sm">No changelog entries loaded.</p>
}
return (
<>
<div className="pb-4 container mx-auto max-w-xl">
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
</div>
<section className="relative flex w-full flex-col">
<div className="container mx-auto max-w-xl min-h-[42vh] w-full">
{loadingDoc && <GenericSkeletonLoader />}
{!loadingDoc && doc && (
<div className="flex flex-col gap-6 pb-12">
<div
className={cn(
'sticky top-16 z-20 md:-mx-4 md:w-[calc(100%+4rem)] flex flex-col gap-1 border-default border-b md:px-4 py-3',
'bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/95'
)}
>
{doc.title && (
<Link href={doc.url}>
<h3 className="text-foreground text-lg">{doc.title}</h3>
</Link>
)}
<p className="text-foreground-lighter text-xs font-mono">
{dayjs(doc.created_at).format('MMM D, YYYY')}
</p>
{labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5">
{labels.map((label) => (
<a
key={`${doc.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 lowercase px-1.5 py-px text-[11px]">
{label.name}
</Badge>
</a>
))}
</div>
)}
{doc.error && <p className="text-destructive-600 pt-1 text-sm">{doc.error}</p>}
</div>
{doc.source ? (
<article className="prose prose-docs max-w-none [overflow-wrap:break-word]">
<MDXRemote {...doc.source} components={mdxComponents('blog')} />
</article>
) : (
!doc.error && <p className="text-foreground-lighter text-sm">No content.</p>
)}
</div>
)}
</div>
<div className="h-32 shrink-0" aria-hidden />
<div
className={cn(
'[background-image:linear-gradient(to_top,hsl(var(--background-default))_0%,hsl(var(--background-default))_75%,hsl(var(--background-default)/0)_100%)]',
'sticky bottom-0 z-30 w-[100vw] max-w-none shrink-0 py-2',
'[margin-inline:calc(50%-50vw)]',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
)}
>
<div
ref={scrollRef}
className="w-full min-w-0 overflow-x-auto overflow-y-hidden [-ms-overflow-style:none] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border"
>
<TooltipProvider>
<div
className="relative min-h-0 select-none"
style={{
width: canvasWidth,
minWidth: '100%',
height: TIMELINE_HEIGHT,
}}
onPointerDown={handlePointerDownStrip}
onPointerLeave={(e) => {
if (isDragging) endDrag(e)
}}
onPointerMove={handlePointerMoveStrip}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 block text-border"
focusable="false"
role="presentation"
height={TIMELINE_HEIGHT}
viewBox={`0 0 ${canvasWidth} ${TIMELINE_HEIGHT}`}
width={canvasWidth}
>
<line
className="stroke-border"
strokeWidth={1}
x1={0}
x2={canvasWidth}
y1={AXIS_Y}
y2={AXIS_Y}
/>
{dayTicks.map(({ day, x, kind }) => {
const h = kind === 'year' ? TICK_YEAR : kind === 'month' ? TICK_MONTH : TICK_DAY
return (
<line
key={day.valueOf()}
className="stroke-border-strong"
strokeWidth={kind === 'year' ? 1.25 : kind === 'month' ? 1 : 0.75}
x1={x}
x2={x}
y1={AXIS_Y}
y2={AXIS_Y - h}
/>
)
})}
{todayMarkX != null ? (
<g aria-hidden>
<line
className="stroke-foreground-muted/45"
strokeDasharray="3 4"
strokeWidth={1}
x1={todayMarkX}
y1={AXIS_Y / 2 + 20}
x2={todayMarkX}
y2={AXIS_Y}
/>
</g>
) : null}
</svg>
{todayMarkX != null ? (
<div
aria-hidden
className="group/today absolute w-10 h-10 z-[5] -translate-x-1/2 flex flex-col items-center text-foreground-muted/70"
style={{ left: todayMarkX, top: AXIS_Y / 2 - 12 }}
>
<span className="opacity-0 group-hover/today:opacity-100 transition-opacity text-center text-xs text-foreground-muted">
Today
</span>
<Sun size={12} strokeWidth={1.8} />
</div>
) : 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 (
<Tooltip delayDuration={0} key={item.number}>
<div
data-changelog-v5-entry
aria-current={active ? 'true' : undefined}
className={cn(
'absolute z-[1] -translate-x-1/2',
'hover:z-[60] focus-within:z-[60]',
active && 'z-[40]'
)}
style={{
left: x,
top: 0,
width: hitW,
height: AXIS_Y,
}}
>
<span
aria-hidden
className={cn(
'pointer-events-none absolute left-1/2 w-px -translate-x-1/2',
active ? 'bg-brand/60' : 'bg-border-strong'
)}
style={{
top: stemTop,
height: stemHeight,
}}
/>
<button
type="button"
tabIndex={-1}
aria-hidden
className="absolute inset-x-0 cursor-pointer border-0 bg-transparent p-0"
style={{ top: stemTop, height: stemHeight }}
onClick={() => handleSelectEntry(item.number)}
/>
<TooltipTrigger asChild>
<button
type="button"
aria-label={item.title}
onClick={() => handleSelectEntry(item.number)}
className={cn(
'group absolute left-1/2 z-[2] flex -translate-x-1/2 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 outline-none',
'focus-visible:ring-brand-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background'
)}
style={{
top: dotTriggerTop,
width: hitW,
height: dotTriggerHeight,
}}
>
<span
aria-hidden
className={cn(
'block shrink-0 rounded-full transition-transform',
active
? 'bg-brand ring-background-surface-100 ring-2'
: 'bg-foreground-muted hover:bg-foreground-light hover:scale-110 group-focus-visible:scale-110'
)}
style={{
width: ENTRY_DOT_DIAMETER_PX,
height: ENTRY_DOT_DIAMETER_PX,
}}
/>
</button>
</TooltipTrigger>
</div>
<TooltipContent
side="top"
align="center"
sideOffset={2}
collisionPadding={4}
className="max-w-screen line-clamp-1 text-left text-xs leading-snug px-1.5 py-1"
>
{item.title}
</TooltipContent>
</Tooltip>
)
})}
{dayTicks
.filter((t) => t.kind !== 'day')
.map(({ day, x, kind }) => (
<div
key={`lbl-${day.valueOf()}`}
className="text-foreground-lighter pointer-events-none absolute z-0 text-center font-mono text-[10px] leading-none"
style={{
left: x,
top: AXIS_Y + 6,
transform: 'translateX(-50%)',
width: kind === 'year' ? 48 : 40,
}}
>
{kind === 'year' ? day.format('YYYY') : day.format('MMM')}
</div>
))}
</div>
</TooltipProvider>
</div>
</div>
</section>
</>
)
}
+1 -4
View File
@@ -385,10 +385,7 @@ function ChangelogV2Page({ changelogIndex, changelog, pageInfo, restPage }: Chan
>
<div className="pb-4">
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter text-lg">
New updates and product improvements{' '}
<span className="text-foreground font-mono text-sm">(v2 — full index)</span>
</p>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
</div>
<section
+1 -22
View File
@@ -126,18 +126,7 @@ function ChangelogV3ModalContent({ changelogIndex }: PageProps) {
<div className="container mx-auto max-w-3xl flex flex-col gap-6 px-4 py-10 sm:px-16 xl:px-20">
<div>
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter mt-2 text-lg">
Same index as v3, but entries open in a modal (experiment). Prefer full pages?{' '}
<Link href="/changelog-v3" className="text-brand-link hover:underline">
Use the list + detail layout
</Link>
. Share or bookmark a specific entry with the GitHub discussion number in the URL,
e.g.{' '}
<code className="text-foreground-lighter bg-surface-200 rounded px-1 py-0.5 font-mono text-sm">
?discussion=123
</code>
.
</p>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
</div>
<section aria-label="Changelog entries">
@@ -167,16 +156,6 @@ function ChangelogV3ModalContent({ changelogIndex }: PageProps) {
{dayjs(displayDateIso).format('MMM D, YYYY')}
</p>
)}
{displayUrl && (
<a
href={displayUrl}
className="text-brand-link text-sm hover:underline"
target="_blank"
rel="noreferrer"
>
Open on GitHub
</a>
)}
{error && <span className="text-destructive-600 text-sm">{error}</span>}
</div>
</DialogDescription>
+1 -7
View File
@@ -40,13 +40,7 @@ function ChangelogV3Page({ changelogIndex }: PageProps) {
<div className="container mx-auto max-w-3xl flex flex-col gap-6 px-4 py-10 sm:px-16 xl:px-20">
<div>
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter mt-2 text-lg">
List-first layout (experiment). Open an entry on this site, or try the{' '}
<Link href="/changelog-v3-modal" className="text-brand-link hover:underline">
modal variant
</Link>
.
</p>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
</div>
<section aria-label="Changelog entries">
+29 -28
View File
@@ -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({
}}
/>
<DefaultLayout>
<div className="container mx-auto max-w-3xl flex flex-col gap-8 px-4 py-10 sm:px-16 xl:px-20">
<div className="container mx-auto max-w-3xl flex flex-col gap-4 px-4 py-10 sm:px-16 xl:px-20">
<nav
aria-label="Breadcrumb"
className="text-foreground-lighter flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"
>
<Link href="/changelog-v3" className="text-brand-link hover:underline">
<Link href="/changelog-v3" className="text-foreground-lighter hover:underline">
Changelog
</Link>
<span aria-hidden>/</span>
<span className="text-foreground max-w-[min(100%,48rem)] truncate font-medium">
{title}
</span>
</nav>
<header className="border-default flex flex-col gap-2 border-b pb-6">
<h1 className="h1 text-2xl sm:text-3xl">{title}</h1>
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(created_at).format('MMM D, YYYY')}
</p>
<a
href={url}
className="text-brand-link w-fit text-sm hover:underline"
target="_blank"
rel="noreferrer"
>
View discussion on GitHub
</a>
<div className="flex items-center justify-between gap-2">
<p className="text-foreground-lighter font-mono text-xs">
{dayjs(created_at).format('MMM D, YYYY')}
</p>
<Link
target="_blank"
href={url}
className="flex items-center gap-2 text-sm text-foreground-lighter hover:text-foreground-light"
rel="noreferrer"
>
View discussion on GitHub
<ArrowUpRightIcon size={14} />
</Link>
</div>
</header>
<article className="prose prose-docs max-w-none [overflow-wrap:break-word]">
@@ -145,22 +145,23 @@ function ChangelogV3DetailPage({
</article>
<div className="flex flex-wrap items-center justify-between gap-4 border-t pt-6">
{prevNumber != null ? (
<Link
href={`/changelog-v3/${prevNumber}`}
className="text-foreground-lighter flex items-center gap-2 text-sm hover:text-foreground"
>
<ArrowLeftIcon className="h-4 w-4" /> Older
</Link>
) : (
<span />
)}
{nextNumber != null ? (
<Link
href={`/changelog-v3/${nextNumber}`}
className="text-foreground-lighter flex items-center gap-2 text-sm hover:text-foreground"
>
Newer <ArrowRightIcon className="h-4 w-4" />
<ArrowLeftIcon className="h-4 w-4" /> Newer
</Link>
) : (
<span />
)}
{prevNumber != null ? (
<Link
href={`/changelog-v3/${prevNumber}`}
className="text-foreground-lighter flex items-center gap-2 text-sm hover:text-foreground"
>
Older
<ArrowRightIcon className="h-4 w-4" />
</Link>
) : (
<span />
+10 -18
View File
@@ -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)
}}
/>
<DefaultLayout>
<div className="container mx-auto max-w-5xl flex flex-col gap-6 px-4 py-10 sm:px-16 xl:px-20">
<div>
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter mt-2 text-lg">
Month tabs experiment — strip stays sticky while you scroll. Full entries load for
the selected month (same MDX as the main changelog).{' '}
<Link href="/changelog-v3" className="text-brand-link hover:underline">
v3 timeline
</Link>
{' · '}
<Link href="/changelog-v3-modal" className="text-brand-link hover:underline">
v3 modal
</Link>
.
</p>
</div>
<NuqsAdapter>
<div className="container mx-auto max-w-5xl flex flex-col gap-6 px-4 py-10 sm:px-16 xl:px-20">
<div>
<h1 className="h1">Changelog</h1>
<p className="text-foreground-lighter text-lg">New updates and product improvements</p>
</div>
<ChangelogMonthTabExplorer items={changelogIndex} />
</div>
<ChangelogMonthTabExplorer items={changelogIndex} />
</div>
</NuqsAdapter>
<CTABanner />
</DefaultLayout>
</>
+54
View File
@@ -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<PageProps> = 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 (
<>
<NextSeo
title="Changelog"
description="New updates and improvements to Supabase"
openGraph={{
title: 'Changelog (v5 bottom timeline)',
url: 'https://supabase.com/changelog-v5',
type: 'article',
}}
/>
<DefaultLayout>
<NuqsAdapter>
<div
className="
mx-auto flex flex-col
gap-8
px-4 py-10 sm:px-16
xl:px-20
"
>
<ChangelogV5Explorer items={changelogIndex} />
</div>
</NuqsAdapter>
<CTABanner />
</DefaultLayout>
</>
)
}
+4 -8
View File
@@ -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"
>
<span
className="border-default bg-surface-200 text-foreground-light rounded-md border border-l-[3px] px-1.5 py-px text-[11px] font-medium leading-tight"
style={{
borderLeftColor: `#${githubLabelHex(label.color)}`,
}}
>
<Badge className="group-hover:text-foreground-light text-foreground-lighter group-hover:border-foreground-muted lowercase px-1.5 py-px text-[11px]">
{label.name}
</span>
</Badge>
</a>
))}
</div>
+15 -1
View File
@@ -4,9 +4,23 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Thu, 02 Apr 2026 00:00:00 -0700</lastBuildDate>
<lastBuildDate>Thu, 09 Apr 2026 00:00:00 -0700</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/blog/supabase-agent-skills</guid>
<title>AI Agents Know About Supabase. They Don&apos;t Always Use It Right.</title>
<link>https://supabase.com/blog/supabase-agent-skills</link>
<description>Introducing Supabase Agent Skills: an open-source set of instructions that teach AI coding agents how to build on Supabase correctly.</description>
<pubDate>Thu, 09 Apr 2026 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/custom-oauth-oidc-providers</guid>
<title>Custom OIDC Providers for Supabase Auth</title>
<link>https://supabase.com/blog/custom-oauth-oidc-providers</link>
<description>Connect any OpenID Connect identity provider to your Supabase project: GitHub Enterprise, regional providers, and more.</description>
<pubDate>Wed, 08 Apr 2026 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/100000-github-stars</guid>
<title>100,000 GitHub stars</title>
<link>https://supabase.com/blog/100000-github-stars</link>