mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
changelog v5
This commit is contained in:
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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'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>
|
||||
|
||||
Reference in new issue
Block a user