Added on-demand webinars as an option in the event list (#46215)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Added a pill for on-demand webinars in the /events page. Previously,
webinars were not shown if they had passed. But we embed the video for
the webinar into the landing page so people can discover and find it.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an "On-demand" event category with dedicated filtering and a
separate on-demand view
* Events page now loads on-demand content and shows "On-demand" badges
where appropriate

* **UI/UX Improvements**
* Banner hides when viewing on-demand events; subtitle updates per view
* Event card layout and badge placement refined; sorting and empty-state
messaging adapt to selected category

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46215?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alan Daniel <stylesshjs@gmail.com>
This commit is contained in:
Prashant SridharanandAlan Daniel authored and GitHub committed 2026-05-21 16:20:17 +01:00
1 parent dd2d070dbe
commit 8cd39680ef
7 files changed
+152 -88

No files matched your search

+24 -7
View File
@@ -21,9 +21,15 @@ interface EventsProviderProps {
children: ReactNode
notionEvents: SupabaseEvent[]
mdxEvents: SupabaseEvent[]
onDemandMdxEvents: SupabaseEvent[]
}
export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) {
export function EventsProvider({
children,
notionEvents,
mdxEvents,
onDemandMdxEvents,
}: EventsProviderProps) {
const [lumaEvents, setLumaEvents] = useState<SupabaseEvent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
@@ -108,6 +114,8 @@ export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProv
})
}, [notionEvents, mdxEvents, lumaEvents])
const isOnDemandView = selectedCategories.includes('on-demand')
const categories = useMemo(() => {
const counts: { [key: string]: number } = { all: 0 }
@@ -118,17 +126,26 @@ export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProv
})
})
if (onDemandMdxEvents.length > 0) {
counts['on-demand'] = onDemandMdxEvents.length
}
return counts
}, [allEvents])
}, [allEvents, onDemandMdxEvents.length])
const toggleCategory = (category: string) => {
if (category === 'on-demand') {
setSelectedCategories((prev) => (prev.includes('on-demand') ? ['all'] : ['on-demand']))
return
}
if (category === 'all') {
setSelectedCategories(['all'])
return
}
setSelectedCategories((prev) => {
const withoutAll = prev.filter((c) => c !== 'all')
const withoutAll = prev.filter((c) => c !== 'all' && c !== 'on-demand')
if (withoutAll.includes(category)) {
const updated = withoutAll.filter((c) => c !== category)
@@ -140,9 +157,9 @@ export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProv
}
const filteredEvents = useMemo(() => {
let filtered = allEvents
let filtered = isOnDemandView ? onDemandMdxEvents : allEvents
if (!selectedCategories.includes('all')) {
if (!isOnDemandView && !selectedCategories.includes('all')) {
filtered = filtered.filter((event) =>
event.categories?.some((cat) => selectedCategories.includes(cat))
)
@@ -163,9 +180,9 @@ export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProv
return [...filtered].sort((a, b) => {
const dateA = new Date(a.date).getTime()
const dateB = new Date(b.date).getTime()
return dateA - dateB
return isOnDemandView ? dateB - dateA : dateA - dateB
})
}, [allEvents, selectedCategories, searchQuery])
}, [allEvents, onDemandMdxEvents, isOnDemandView, selectedCategories, searchQuery])
const featuredEvent = useMemo(() => {
if (allEvents.length === 0) return undefined
+8 -3
View File
@@ -1,6 +1,6 @@
import { EventClientRenderer } from '~/components/Events/new/EventClientRenderer'
import { breadcrumbs } from '~/lib/breadcrumbs'
import { getMdxEvents, getNotionEvents } from '~/lib/events'
import { getMdxEvents, getNotionEvents, getOnDemandMdxEvents } from '~/lib/events'
import { breadcrumbListSchema, serializeJsonLd } from '~/lib/json-ld'
import type { Metadata } from 'next'
@@ -11,9 +11,10 @@ export const metadata: Metadata = {
}
export default async function EventsPage() {
const [notionEvents, mdxEvents] = await Promise.all([
const [notionEvents, mdxEvents, onDemandMdxEvents] = await Promise.all([
getNotionEvents(),
Promise.resolve(getMdxEvents()),
Promise.resolve(getOnDemandMdxEvents()),
])
return (
@@ -24,7 +25,11 @@ export default async function EventsPage() {
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.eventsIndex)),
}}
/>
<EventClientRenderer notionEvents={notionEvents} mdxEvents={mdxEvents} />
<EventClientRenderer
notionEvents={notionEvents}
mdxEvents={mdxEvents}
onDemandMdxEvents={onDemandMdxEvents}
/>
</>
)
}
@@ -9,7 +9,8 @@ import { EventGallery } from './EventGallery'
import { EventsContainer } from './EventsContainer'
function EventBannerSection() {
const { isLoading, featuredEvent } = useEvents()
const { isLoading, featuredEvent, selectedCategories } = useEvents()
if (selectedCategories.includes('on-demand')) return null
if (!isLoading && !featuredEvent) return null
return (
@@ -19,21 +20,39 @@ function EventBannerSection() {
)
}
function EventsPageSubtitle() {
const { selectedCategories } = useEvents()
return (
<p className="text-foreground-light">
{selectedCategories.includes('on-demand')
? 'Watch recordings from past webinars and sessions.'
: 'Explore upcoming events and on-demand recordings.'}
</p>
)
}
export function EventClientRenderer({
notionEvents,
mdxEvents,
onDemandMdxEvents,
}: {
notionEvents: SupabaseEvent[]
mdxEvents: SupabaseEvent[]
onDemandMdxEvents: SupabaseEvent[]
}) {
return (
<EventsProvider notionEvents={notionEvents} mdxEvents={mdxEvents}>
<EventsProvider
notionEvents={notionEvents}
mdxEvents={mdxEvents}
onDemandMdxEvents={onDemandMdxEvents}
>
<DefaultLayout className="flex flex-col">
<EventsContainer className="border-x border-b py-8">
<h1 className="h3 p-0! m-0!">
<span className="sr-only">Supabase</span> Events
</h1>
<p className="text-foreground-light">Join us at the following upcoming events</p>
<EventsPageSubtitle />
</EventsContainer>
<EventBannerSection />
@@ -1,11 +1,12 @@
import { cn } from 'ui'
import { EventGalleryFilters } from './EventGalleryFilters'
import { EventList } from './EventList'
import { EventTimeline } from './EventTimeline'
export function EventGallery() {
return (
<section className={cn('grid md:grid-cols-[minmax(320px,35%)_16px_1fr] gap-12')}>
<section className={cn('grid md:grid-cols-[minmax(320px,35%)_16px_1fr] gap-12 min-h-[60vh]')}>
<div className="sticky top-16 md:top-24 py-4 bg-background self-start z-10">
<EventGalleryFilters />
</div>
@@ -46,6 +46,15 @@ export function EventGalleryFilters() {
</Badge>
)
})}
{(categories['on-demand'] ?? 0) > 0 && (
<Badge
variant={selectedCategories.includes('on-demand') ? 'success' : 'default'}
className="cursor-pointer"
onClick={() => toggleCategory('on-demand')}
>
On-demand ({categories['on-demand']})
</Badge>
)}
</div>
</div>
)
+15 -13
View File
@@ -17,7 +17,8 @@ const CATEGORIES_FILTERS = [
]
export function EventList() {
const { isLoading, filteredEvents } = useEvents()
const { isLoading, filteredEvents, selectedCategories } = useEvents()
const isOnDemandView = selectedCategories.includes('on-demand')
const getCategoryLabel = (value: string) => {
const category = CATEGORIES_FILTERS.find((cat) => cat.value === value)
@@ -50,10 +51,14 @@ export function EventList() {
)
const hasEvents = Object.keys(eventsByDate).length > 0
const sortedDateGroups = Object.entries(eventsByDate).sort(([, eventsA], [, eventsB]) => {
if (!isOnDemandView) return 0
return new Date(eventsB[0].date).getTime() - new Date(eventsA[0].date).getTime()
})
return (
<div className="flex flex-col gap-y-8 min-h-72">
{Object.entries(eventsByDate).map(([date, events], index) => (
{sortedDateGroups.map(([date, events], index) => (
<div key={`group-${date}`} className="flex flex-col gap-y-2 relative">
<div
className={cn(
@@ -81,16 +86,9 @@ export function EventList() {
/>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 min-w-0 flex-1">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="leading-snug">{event.title}</h3>
{event.isSpeaking && (
<Badge variant="success" className="flex items-center gap-1">
Speaking
</Badge>
)}
</div>
<h3 className="leading-snug">{event.title}</h3>
{event.end_date && (
<p className="text-xs text-foreground-light">
@@ -124,7 +122,7 @@ export function EventList() {
)}
</div>
<div className="flex items-center gap-2 relative z-10">
<div className="flex flex-wrap items-center justify-end gap-2 shrink-0 relative z-10">
{event.meetingLink && (
<Button size="tiny" type="secondary" asChild>
<Link href={event.meetingLink} target="_blank" rel="noopener noreferrer">
@@ -132,6 +130,8 @@ export function EventList() {
</Link>
</Button>
)}
{event.onDemand && !isOnDemandView && <Badge variant="default">On-demand</Badge>}
{event.isSpeaking && <Badge variant="success">Speaking</Badge>}
{event.categories.slice(0, 3).map((tag, idx) => (
<Badge key={`tag-${idx}`}>{getCategoryLabel(tag)}</Badge>
))}
@@ -150,7 +150,9 @@ export function EventList() {
{!hasEvents && (
<div className="self-center text-muted my-auto flex flex-col items-center gap-y-4">
<Rows3Icon className="size-8" />
<p className="">Oops! No events found.</p>
<p className="">
{isOnDemandView ? 'No on-demand events found.' : 'Oops! No events found.'}
</p>
</div>
)}
</div>
+72 -61
View File
@@ -201,72 +201,83 @@ function startOfTodayUtc(): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
function readMdxEventFilenames(): string[] {
const postDirectory = path.join(process.cwd(), EVENTS_DIRECTORY)
try {
return fs.readdirSync(postDirectory).filter((filename) => filename.endsWith('.mdx'))
} catch (error) {
console.error('Error reading _events directory:', error)
return []
}
}
function mdxFileToEvent(filename: string): SupabaseEvent | null {
try {
const fullPath = path.join(process.cwd(), EVENTS_DIRECTORY, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents) as unknown as { data: MdxFrontmatter }
if (!data.title || !data.date) return null
const slug = mdxSlugFromFilename(filename)
const categories = data.categories ?? (data.type ? [data.type] : [])
// Webinars don't show a "Hosted by" line — drop hosts entirely.
const isWebinar = data.type === 'webinar' || categories.includes('webinar')
const rawCtaUrl = data.main_cta?.url ?? ''
const isExternalCta = /^https?:\/\//i.test(rawCtaUrl)
const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : ''
const internalPath = `/events/${slug}`
const href = data.disable_page_build ? safeExternalCta || internalPath : internalPath
const target = data.disable_page_build && safeExternalCta ? '_blank' : '_self'
return {
slug,
type: data.type ?? 'event',
title: data.title,
date: data.date,
end_date: data.end_date,
description: data.meta_description ?? data.description ?? data.subtitle ?? '',
thumb: '',
cover_url: '',
path: internalPath,
url: href,
tags: data.tags ?? categories,
categories,
timezone: data.timezone ?? '',
location: '',
hosts: isWebinar ? [] : mdxHostsToEventHosts(data.hosts),
source: 'mdx',
onDemand: data.onDemand,
disable_page_build: data.disable_page_build,
link: { href, target, label: data.main_cta?.label },
}
} catch (error) {
console.error(`Error parsing mdx event ${filename}:`, error)
return null
}
}
let parsedMdxEventsCache: SupabaseEvent[] | null = null
/** Parse every `_events/*.mdx` file once per process and reuse the result. */
function getAllParsedMdxEvents(): SupabaseEvent[] {
if (parsedMdxEventsCache) return parsedMdxEventsCache
parsedMdxEventsCache = readMdxEventFilenames()
.map(mdxFileToEvent)
.filter((e): e is SupabaseEvent => e !== null)
return parsedMdxEventsCache
}
/**
* Read all events under `_events/` and return today-and-future events.
* Past events are excluded (by end_date when present, otherwise start date).
*/
export const getMdxEvents = (): SupabaseEvent[] => {
const postDirectory = path.join(process.cwd(), EVENTS_DIRECTORY)
let fileNames: string[]
try {
fileNames = fs.readdirSync(postDirectory)
} catch (error) {
console.error('Error reading _events directory:', error)
return []
}
const today = startOfTodayUtc()
return getAllParsedMdxEvents().filter((event) => new Date(event.end_date ?? event.date) >= today)
}
return fileNames
.filter((filename) => filename.endsWith('.mdx'))
.map((filename): SupabaseEvent | null => {
try {
const fullPath = path.join(postDirectory, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents) as unknown as { data: MdxFrontmatter }
if (!data.title || !data.date) return null
const eventEnd = new Date(data.end_date ?? data.date)
if (eventEnd < today) return null
const slug = mdxSlugFromFilename(filename)
const categories = data.categories ?? (data.type ? [data.type] : [])
// Webinars don't show a "Hosted by" line — drop hosts entirely.
const isWebinar = data.type === 'webinar' || categories.includes('webinar')
const rawCtaUrl = data.main_cta?.url ?? ''
const isExternalCta = /^https?:\/\//i.test(rawCtaUrl)
const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : ''
const internalPath = `/events/${slug}`
const href = data.disable_page_build ? safeExternalCta || internalPath : internalPath
const target = data.disable_page_build && safeExternalCta ? '_blank' : '_self'
return {
slug,
type: data.type ?? 'event',
title: data.title,
date: data.date,
end_date: data.end_date,
description: data.meta_description ?? data.description ?? data.subtitle ?? '',
thumb: '',
cover_url: '',
path: internalPath,
url: href,
tags: data.tags ?? categories,
categories,
timezone: data.timezone ?? '',
location: '',
hosts: isWebinar ? [] : mdxHostsToEventHosts(data.hosts),
source: 'mdx',
onDemand: data.onDemand,
disable_page_build: data.disable_page_build,
link: { href, target, label: data.main_cta?.label },
}
} catch (error) {
console.error(`Error parsing mdx event ${filename}:`, error)
return null
}
})
.filter((e): e is SupabaseEvent => e !== null)
/** All MDX events with `onDemand: true`, including past recordings. */
export const getOnDemandMdxEvents = (): SupabaseEvent[] => {
return getAllParsedMdxEvents().filter((event) => event.onDemand === true)
}