mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
## 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? Feature ## What is the current behavior? The /events page only loads events from the Notion "Developer Events" database and the Luma Community API. MDX files under `apps/www/_events/` (including webinars like agency-webinar, sentry, datadog, figma-make) are not surfaced on the listing, and past events could still appear until the moment they ended because the filter compared against `now()` rather than the current day. Addresses [DEBR-85](https://linear.app/supabase/issue/DEBR-85/events-page-powered-by-notion-page). ## What is the new behavior? - New `getMdxEvents()` reads `apps/www/_events/*.mdx`, parses frontmatter with `gray-matter`, and returns today-and-future events as `SupabaseEvent`s. - `/events` now merges Notion + mdx + Luma events. - Past events are hidden across all sources by comparing against the start of today (UTC) instead of `now()`, so events running today stay visible throughout the day. ## Additional context Links on mdx events point at the main_cta URL when it's an external \`http(s)\` URL, otherwise fall back to the built \`/events/{slug}\` page so on-demand recordings remain reachable. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Events can be sourced from MDX files and merged into site event listings. * Luma supports multiple calendars (community and hackathon) for richer feeds. * **Improvements** * Events now exclude anything before the start of the current UTC day. * Added a “Community Event” category filter and included it in counts. * Event title typography adjusted for improved readability. * “Hosted by” text now only shows when hosts exist; host fallbacks standardized. * **Chores** * Build env updated to include hackathon API key. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import { SUPABASE_HOST, SupabaseEvent } from '~/lib/eventsTypes'
|
|
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
|
|
|
interface EventsContextValue {
|
|
allEvents: SupabaseEvent[]
|
|
filteredEvents: SupabaseEvent[]
|
|
featuredEvent: SupabaseEvent | undefined
|
|
isLoading: boolean
|
|
searchQuery: string
|
|
setSearchQuery: (query: string) => void
|
|
selectedCategories: string[]
|
|
toggleCategory: (category: string) => void
|
|
categories: { [key: string]: number }
|
|
}
|
|
|
|
const EventsContext = createContext<EventsContextValue | undefined>(undefined)
|
|
|
|
interface EventsProviderProps {
|
|
children: ReactNode
|
|
notionEvents: SupabaseEvent[]
|
|
mdxEvents: SupabaseEvent[]
|
|
}
|
|
|
|
export function EventsProvider({ children, notionEvents, mdxEvents }: EventsProviderProps) {
|
|
const [lumaEvents, setLumaEvents] = useState<SupabaseEvent[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(['all'])
|
|
|
|
useEffect(() => {
|
|
const fetchLumaEvents = async () => {
|
|
try {
|
|
setIsLoading(true)
|
|
|
|
const afterDate = new Date().toISOString()
|
|
const url = new URL('/api-v2/luma-events', window.location.origin)
|
|
url.searchParams.append('after', afterDate)
|
|
|
|
const res = await fetch(url.toString())
|
|
const data = await res.json()
|
|
|
|
if (data.success) {
|
|
const transformedEvents: SupabaseEvent[] = data.events.map((event: any) => {
|
|
// Categorize by the originating Luma calendar: events from the
|
|
// Supabase Hackathons calendar → `hackathon`; everything from the
|
|
// Supabase Community Events calendar → `community`.
|
|
const categories: string[] = [
|
|
event?.calendar === 'hackathon' ? 'hackathon' : 'community',
|
|
]
|
|
|
|
const rawUrl = event?.url || ''
|
|
let safeUrl: string | undefined
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
safeUrl = rawUrl
|
|
}
|
|
} catch {
|
|
// invalid URL
|
|
}
|
|
|
|
return {
|
|
slug: '',
|
|
type: 'event',
|
|
title: event?.name || '',
|
|
date: event?.start_at || '',
|
|
description: event?.description || '',
|
|
thumb: '',
|
|
cover_url: event?.cover_url || '',
|
|
path: '',
|
|
url: safeUrl ?? '',
|
|
tags: categories,
|
|
categories,
|
|
timezone: event?.timezone || 'America/Los_Angeles',
|
|
location: new Intl.ListFormat('en', { style: 'narrow', type: 'unit' }).format(
|
|
[event?.city, event?.country].filter(Boolean)
|
|
),
|
|
// All Luma events are Supabase-hosted regardless of which calendar they're from.
|
|
hosts: [SUPABASE_HOST],
|
|
source: 'luma' as const,
|
|
disable_page_build: true,
|
|
link: safeUrl ? { href: safeUrl, target: '_blank' as const } : undefined,
|
|
}
|
|
})
|
|
setLumaEvents(transformedEvents)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching Luma events:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchLumaEvents()
|
|
}, [])
|
|
|
|
// Merge Notion (server) + mdx (server) + Luma (client) events, showing only today and future.
|
|
const allEvents = useMemo(() => {
|
|
const now = new Date()
|
|
const startOfToday = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
)
|
|
return [...notionEvents, ...mdxEvents, ...lumaEvents].filter((event) => {
|
|
const eventDate = new Date(event.end_date || event.date)
|
|
return eventDate >= startOfToday
|
|
})
|
|
}, [notionEvents, mdxEvents, lumaEvents])
|
|
|
|
const categories = useMemo(() => {
|
|
const counts: { [key: string]: number } = { all: 0 }
|
|
|
|
allEvents.forEach((event) => {
|
|
counts.all += 1
|
|
event.categories?.forEach((category) => {
|
|
counts[category] = (counts[category] || 0) + 1
|
|
})
|
|
})
|
|
|
|
return counts
|
|
}, [allEvents])
|
|
|
|
const toggleCategory = (category: string) => {
|
|
if (category === 'all') {
|
|
setSelectedCategories(['all'])
|
|
return
|
|
}
|
|
|
|
setSelectedCategories((prev) => {
|
|
const withoutAll = prev.filter((c) => c !== 'all')
|
|
|
|
if (withoutAll.includes(category)) {
|
|
const updated = withoutAll.filter((c) => c !== category)
|
|
return updated.length === 0 ? ['all'] : updated
|
|
} else {
|
|
return [...withoutAll, category]
|
|
}
|
|
})
|
|
}
|
|
|
|
const filteredEvents = useMemo(() => {
|
|
let filtered = allEvents
|
|
|
|
if (!selectedCategories.includes('all')) {
|
|
filtered = filtered.filter((event) =>
|
|
event.categories?.some((cat) => selectedCategories.includes(cat))
|
|
)
|
|
}
|
|
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase()
|
|
filtered = filtered.filter((event) => {
|
|
const titleMatch = event.title?.toLowerCase().includes(query)
|
|
const descriptionMatch = event.description?.toLowerCase().includes(query)
|
|
const locationMatch = event.location?.toLowerCase().includes(query)
|
|
const tagsMatch = event.tags?.some((tag) => tag.toLowerCase().includes(query))
|
|
|
|
return titleMatch || descriptionMatch || locationMatch || tagsMatch
|
|
})
|
|
}
|
|
|
|
return [...filtered].sort((a, b) => {
|
|
const dateA = new Date(a.date).getTime()
|
|
const dateB = new Date(b.date).getTime()
|
|
return dateA - dateB
|
|
})
|
|
}, [allEvents, selectedCategories, searchQuery])
|
|
|
|
const featuredEvent = useMemo(() => {
|
|
if (allEvents.length === 0) return undefined
|
|
|
|
return [...allEvents].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())[0]
|
|
}, [allEvents])
|
|
|
|
const value: EventsContextValue = {
|
|
allEvents,
|
|
filteredEvents,
|
|
featuredEvent,
|
|
isLoading,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
selectedCategories,
|
|
toggleCategory,
|
|
categories,
|
|
}
|
|
|
|
return <EventsContext.Provider value={value}>{children}</EventsContext.Provider>
|
|
}
|
|
|
|
export function useEvents() {
|
|
const context = useContext(EventsContext)
|
|
if (context === undefined) {
|
|
throw new Error('useEvents must be used within an EventsProvider')
|
|
}
|
|
return context
|
|
}
|