diff --git a/apps/www/app/events/context.tsx b/apps/www/app/events/context.tsx index 6de7bd3b10c..d89c1c67869 100644 --- a/apps/www/app/events/context.tsx +++ b/apps/www/app/events/context.tsx @@ -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([]) 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 diff --git a/apps/www/app/events/page.tsx b/apps/www/app/events/page.tsx index ca566397bae..974b803471c 100644 --- a/apps/www/app/events/page.tsx +++ b/apps/www/app/events/page.tsx @@ -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)), }} /> - + ) } diff --git a/apps/www/components/Events/new/EventClientRenderer.tsx b/apps/www/components/Events/new/EventClientRenderer.tsx index 3693dacc615..708632b9f63 100644 --- a/apps/www/components/Events/new/EventClientRenderer.tsx +++ b/apps/www/components/Events/new/EventClientRenderer.tsx @@ -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 ( +

+ {selectedCategories.includes('on-demand') + ? 'Watch recordings from past webinars and sessions.' + : 'Explore upcoming events and on-demand recordings.'} +

+ ) +} + export function EventClientRenderer({ notionEvents, mdxEvents, + onDemandMdxEvents, }: { notionEvents: SupabaseEvent[] mdxEvents: SupabaseEvent[] + onDemandMdxEvents: SupabaseEvent[] }) { return ( - +

Supabase Events

-

Join us at the following upcoming events

+
diff --git a/apps/www/components/Events/new/EventGallery.tsx b/apps/www/components/Events/new/EventGallery.tsx index c03063ed3e7..b59c0413244 100644 --- a/apps/www/components/Events/new/EventGallery.tsx +++ b/apps/www/components/Events/new/EventGallery.tsx @@ -1,11 +1,12 @@ import { cn } from 'ui' + import { EventGalleryFilters } from './EventGalleryFilters' import { EventList } from './EventList' import { EventTimeline } from './EventTimeline' export function EventGallery() { return ( -
+
diff --git a/apps/www/components/Events/new/EventGalleryFilters.tsx b/apps/www/components/Events/new/EventGalleryFilters.tsx index a221249aaff..0883dffdee7 100644 --- a/apps/www/components/Events/new/EventGalleryFilters.tsx +++ b/apps/www/components/Events/new/EventGalleryFilters.tsx @@ -46,6 +46,15 @@ export function EventGalleryFilters() { ) })} + {(categories['on-demand'] ?? 0) > 0 && ( + toggleCategory('on-demand')} + > + On-demand ({categories['on-demand']}) + + )} ) diff --git a/apps/www/components/Events/new/EventList.tsx b/apps/www/components/Events/new/EventList.tsx index f850bb50bbd..14a33b765b5 100644 --- a/apps/www/components/Events/new/EventList.tsx +++ b/apps/www/components/Events/new/EventList.tsx @@ -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 (
- {Object.entries(eventsByDate).map(([date, events], index) => ( + {sortedDateGroups.map(([date, events], index) => (
)} -
+
-
-

{event.title}

- {event.isSpeaking && ( - - Speaking - - )} -
+

{event.title}

{event.end_date && (

@@ -124,7 +122,7 @@ export function EventList() { )}

-
+
{event.meetingLink && ( )} + {event.onDemand && !isOnDemandView && On-demand} + {event.isSpeaking && Speaking} {event.categories.slice(0, 3).map((tag, idx) => ( {getCategoryLabel(tag)} ))} @@ -150,7 +150,9 @@ export function EventList() { {!hasEvents && (
-

Oops! No events found.

+

+ {isOnDemandView ? 'No on-demand events found.' : 'Oops! No events found.'} +

)}
diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts index 6114c73b223..5a68eb861e2 100644 --- a/apps/www/lib/events.ts +++ b/apps/www/lib/events.ts @@ -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) }