mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 --> [](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:
1 parent
dd2d070dbe
commit
8cd39680ef
7 files changed
+152
-88
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user