@@ -77,7 +73,7 @@ const maskEven =
const maskOdd =
"repeating-linear-gradient(to right, black, black 12px, transparent 12px, transparent 24px)"
-function Stripes() {
+export function HeroStripes() {
return (
()
+
export interface ImageLoadedProps extends React.HTMLAttributes {
image: string | StaticImageData
}
export function ImageLoaded({ image, ...rest }: ImageLoadedProps) {
const [loaded, setLoaded] = useState(false)
+ const src = typeof image === "string" ? image : image.src
+
+ const alreadyLoaded = _cache.get(src)?.complete
useEffect(() => {
- const img = new Image()
- const src = typeof image === "string" ? image : image.src
- img.src = src
- img.onload = () => setLoaded(true)
- }, [image])
+ let img: HTMLImageElement
+ if (_cache.has(src)) {
+ img = _cache.get(src)!
+ if (img.complete) {
+ setLoaded(true)
+ } else {
+ img.addEventListener("load", () => setLoaded(true))
+ }
+ } else {
+ img = new Image()
+ img.src = src
+ img.addEventListener("load", () => setLoaded(true))
+ _cache.set(src, img)
+ }
+ }, [src])
- return
+ return
}
diff --git a/src/app/conf/2025/components/navbar.tsx b/src/app/conf/2025/components/navbar.tsx
index 2edbacc0a0..61fb6b46d7 100644
--- a/src/app/conf/2025/components/navbar.tsx
+++ b/src/app/conf/2025/components/navbar.tsx
@@ -22,6 +22,7 @@ export function Navbar({ links, year }: NavbarProps): ReactElement {
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false)
const handleDrawerClick = useCallback(() => {
+ // todo: block scrolling on body
setMobileDrawerOpen(prev => !prev)
}, [])
diff --git a/src/app/conf/2025/components/speaker-card.tsx b/src/app/conf/2025/components/speaker-card.tsx
new file mode 100644
index 0000000000..55f71f70a4
--- /dev/null
+++ b/src/app/conf/2025/components/speaker-card.tsx
@@ -0,0 +1,126 @@
+import clsx from "clsx"
+import Image from "next-image-export-optimizer"
+import type { StaticImageData } from "next/image"
+
+import TwitterXIcon from "@/icons/twitter.svg?svgr"
+import LinkedInIcon from "@/icons/linkedin.svg?svgr"
+import { eventsColors } from "../utils"
+
+import { Anchor } from "../../_design-system/anchor"
+import { Tag } from "../../_design-system/tag"
+import { SchedSpeaker } from "../../2023/types"
+import {
+ SocialMediaIcon,
+ SocialMediaIconServiceType,
+} from "../../_components/speakers/social-media"
+
+export interface SpeakerCardProps extends React.HTMLAttributes {
+ imageUrl?: string | StaticImageData
+ tags?: string[]
+ isReturning?: boolean
+ stripes?: string
+ speaker: SchedSpeaker
+ year: string
+}
+
+function Stripes({ mask }: { mask?: string }) {
+ return (
+
+ )
+}
+
+export function SpeakerCard({
+ imageUrl,
+ tags = [],
+ className,
+ speaker,
+ year,
+ ...props
+}: SpeakerCardProps) {
+ return (
+
+
+ {imageUrl && (
+
+ )}
+
+
+
{speaker.name}
+
+ {[speaker.position, speaker.company].filter(Boolean).join(", ")}
+
+
+ {speaker.about && (
+
{speaker.about}
+ )}
+ {/* TODO: We'll have to collect it when fetching all sessions. */}
+ {tags.length > 0 && (
+
+ {tags.map(tag => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {speaker.socialurls?.length ? (
+
+
+ {speaker.socialurls.map(social => (
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ )
+}
diff --git a/src/app/conf/2025/layout.tsx b/src/app/conf/2025/layout.tsx
index bdd77b4c68..4bb4bfeb77 100644
--- a/src/app/conf/2025/layout.tsx
+++ b/src/app/conf/2025/layout.tsx
@@ -40,9 +40,9 @@ export default function Layout({
-
+ }>
+
+
+
+
+
diff --git a/src/app/conf/2025/schedule/[id]/page.tsx b/src/app/conf/2025/schedule/[id]/page.tsx
new file mode 100644
index 0000000000..c130a76141
--- /dev/null
+++ b/src/app/conf/2025/schedule/[id]/page.tsx
@@ -0,0 +1,206 @@
+import { notFound } from "next/navigation"
+import { Metadata } from "next"
+import clsx from "clsx"
+import { format, parseISO } from "date-fns"
+
+import { metadata as layoutMetadata } from "@/app/conf/2023/layout"
+import { Avatar } from "../../../_components/speakers/avatar"
+import {
+ SocialMediaIcon,
+ SocialMediaIconServiceType,
+} from "../../../_components/speakers/social-media"
+import { speakers, schedule } from "../../_data"
+import { ScheduleSession } from "../../../2023/types"
+
+import { SessionVideo } from "./session-video"
+import { NavbarPlaceholder } from "../../components/navbar"
+import { BackLink } from "../_components/back-link"
+import { Tag } from "@/app/conf/_design-system/tag"
+import { eventsColors } from "../../utils"
+import { PinIcon } from "../../pixelarticons/pin-icon"
+import { CalendarIcon } from "../../pixelarticons/calendar-icon"
+import { SpeakerCard } from "../../components/speaker-card"
+import { Anchor } from "@/app/conf/_design-system/anchor"
+
+function getEventTitle(event: ScheduleSession, speakers: string[]): string {
+ let { name } = event
+
+ if (!speakers) {
+ return name
+ }
+
+ speakers?.forEach(speaker => {
+ const speakerInTitle = name.indexOf(`- ${speaker.replace("ı", "i")}`)
+ if (speakerInTitle > -1) {
+ name = name.slice(0, speakerInTitle)
+ }
+ })
+
+ return name
+}
+
+type SessionProps = { params: { id: string } }
+
+export function generateMetadata({ params }: SessionProps): Metadata {
+ const event = schedule.find(s => s.id === params.id)!
+
+ const keywords = [
+ event.event_type,
+ event.audience,
+ event.event_subtype,
+ ...(event.speakers || []).map(s => s.name),
+ ].filter(Boolean)
+
+ return {
+ title: event.name,
+ description: event.description,
+ keywords: [...layoutMetadata.keywords, ...keywords],
+ openGraph: {
+ images: `/img/__og-image/2024/${event.id}.png`,
+ },
+ }
+}
+
+export function generateStaticParams() {
+ return schedule.filter(s => s.id).map(s => ({ id: s.id }))
+}
+
+export default function SessionPage({ params }: SessionProps) {
+ const event = schedule.find(s => s.id === params.id)
+ if (!event) {
+ notFound()
+ }
+
+ // @ts-expect-error -- fixme
+ event.speakers = (event.speakers || []).map(speaker =>
+ speakers.find(s => s.username === speaker.username),
+ )
+
+ const eventTitle = getEventTitle(
+ event,
+ event.speakers!.map(s => s.name),
+ )
+
+ return (
+
+
+
+
+
+
+
+
+
+ {event.description}
+
+
+
+ {event.files?.map(({ path }) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+function SessionTags({ session }: { session: ScheduleSession }) {
+ const eventType = session.event_type.endsWith("s")
+ ? session.event_type.slice(0, -1)
+ : session.event_type
+
+ return (
+
+ {eventType && (
+ {eventType}
+ )}
+ {session.audience && (
+
+ {session.audience}
+
+ )}
+ {session.event_subtype && (
+
+ {session.event_subtype}
+
+ )}
+
+ )
+}
+
+function SessionHeader({
+ event,
+ eventTitle,
+ year,
+}: {
+ event: ScheduleSession
+ eventTitle: string | null
+ year: number | `${number}`
+}) {
+ const speakers = event.speakers || []
+
+ return (
+
+
+ = 4 ? "typography-body-lg" : "typography-h3",
+ )}
+ >
+ {speakers.map((s, i) => (
+ <>
+
+ {s.name}
+
+ {i !== speakers.length - 1 && , }
+ >
+ ))}
+
+ {eventTitle}
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ )
+}
+
+function SessionSpeakers({ event }: { event: ScheduleSession }) {
+ return (
+
+ {event.speakers!.map(speaker => (
+
+ ))}
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/[id]/session-video.tsx b/src/app/conf/2025/schedule/[id]/session-video.tsx
new file mode 100644
index 0000000000..0575f43432
--- /dev/null
+++ b/src/app/conf/2025/schedule/[id]/session-video.tsx
@@ -0,0 +1,37 @@
+import { findBestMatch } from "string-similarity"
+import { videos } from "../../_videos"
+import { ScheduleSession } from "@/app/conf/2023/types"
+
+export interface SessionVideoProps {
+ eventTitle: string
+ event: ScheduleSession
+}
+
+export function SessionVideo({ eventTitle, event }: SessionVideoProps) {
+ const result = findBestMatch(
+ `${eventTitle} ${event.speakers!.map(e => e.name).join(" ")}`,
+ videos.map(e => e.title),
+ )
+
+ if (result.ratings[result.bestMatchIndex].rating < 0.17) {
+ return null
+ }
+
+ const recordingTitle = result.bestMatch
+
+ const videoId = videos.find(e => e.title === recordingTitle.target)?.id
+
+ if (!videoId) {
+ throw new Error(`Video "${recordingTitle.target}" not found`)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/_components/back-link.tsx b/src/app/conf/2025/schedule/_components/back-link.tsx
new file mode 100644
index 0000000000..5313e30a0a
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/back-link.tsx
@@ -0,0 +1,26 @@
+import NextLink from "next/link"
+import ArrowDownIcon from "../../pixelarticons/arrow-down.svg?svgr"
+
+export function BackLink({
+ year,
+ kind,
+}: {
+ year: "2025"
+ kind: "speakers" | "sessions" | "schedule"
+}) {
+ return (
+
+
+ Back to {capitalize(kind)}
+
+ )
+}
+
+function capitalize(str: string) {
+ return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase()
+}
diff --git a/src/app/conf/2025/schedule/_components/filter-categories.ts b/src/app/conf/2025/schedule/_components/filter-categories.ts
new file mode 100644
index 0000000000..2d055bb872
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/filter-categories.ts
@@ -0,0 +1,75 @@
+import { CategoryName } from "./session-list"
+
+export const filterCategories2024: {
+ name: CategoryName
+ options: string[]
+}[] = [
+ {
+ name: "Audience",
+ options: ["Beginner", "Intermediate", "Advanced"],
+ },
+ {
+ name: "Talk category",
+ options: [
+ "Keynote Sessions",
+ "API Platform",
+ "Federation and Composite Schemas",
+ "GraphQL Clients",
+ "Backend",
+ "Defies Categorization",
+ "Developer Experience",
+ "GraphQL in Production",
+ "GraphQL Security",
+ "GraphQL Spec",
+ "Scaling",
+ ],
+ },
+ {
+ name: "Event type",
+ options: [
+ "Workshops",
+ "Breaks & Special Events",
+ "Keynote Sessions",
+ "Sponsor Showcase",
+ "Session Presentations",
+ "Lightning Talks",
+ ],
+ },
+]
+
+export const filterCategories2023: {
+ name: CategoryName
+ options: string[]
+}[] = [
+ {
+ name: "Audience",
+ options: ["Beginner", "Intermediate", "Advanced"],
+ },
+ {
+ name: "Talk category",
+ options: [
+ "Beyond Javascript",
+ "Spec Fusion",
+ "Platform and Backend",
+ "GraphQL and Data",
+ "GraphQL Security",
+ "GraphQL in Production",
+ "GraphQL Clients",
+ "GraphQL Core",
+ "Scaling",
+ "Emerging Community Trends",
+ ],
+ },
+ {
+ name: "Event type",
+ options: [
+ "Workshops",
+ "Unconference",
+ "Keynote Sessions",
+ "Sponsor Showcase",
+ "Session Presentations",
+ "Lightning Talks",
+ "Events & Experiences",
+ ],
+ },
+]
diff --git a/src/app/conf/2025/schedule/_components/filters.tsx b/src/app/conf/2025/schedule/_components/filters.tsx
new file mode 100644
index 0000000000..50732f380f
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/filters.tsx
@@ -0,0 +1,230 @@
+import clsx from "clsx"
+import { useState } from "react"
+import { Combobox } from "@headlessui/react"
+
+import { Tag } from "@/app/conf/_design-system/tag"
+import { Button } from "@/app/conf/_design-system/button"
+
+import CloseIcon from "@/app/conf/2025/pixelarticons/close.svg?svgr"
+import CaretDownIcon from "@/app/conf/2025/pixelarticons/caret-down.svg?svgr"
+import { eventsColors } from "../../utils"
+type FiltersProps = {
+ categories: { name: string; options: string[] }[]
+ filterState: Record
+ onFilterChange: (category: string, newSelectedOptions: string[]) => void
+}
+
+export function Filters({
+ categories,
+ filterState,
+ onFilterChange,
+}: FiltersProps) {
+ return (
+
+ {categories.map(category => (
+ {
+ onFilterChange(category.name, newSelectedOptions)
+ }}
+ placeholder={`Any ${category.name.toLowerCase()}`}
+ className="flex-1"
+ />
+ ))}
+
+ )
+}
+
+export function ResetFiltersButton({
+ onReset,
+ className,
+ filters,
+}: {
+ filters: Record
+ onReset: () => void
+ className?: string
+}) {
+ const hasFilters = Object.values(filters).flat().length > 0
+
+ return (
+
+ )
+}
+
+interface FiltersComboboxProps {
+ label: string
+ options: string[]
+ value: string[]
+ onChange: (newSelectedOptions: string[]) => void
+ placeholder: string
+ className?: string
+}
+function FiltersCombobox({
+ label,
+ options,
+ value,
+ onChange,
+ placeholder,
+ className,
+}: FiltersComboboxProps) {
+ const [query, setQuery] = useState("")
+
+ const filteredOptions =
+ query === ""
+ ? options
+ : options.filter(option =>
+ option.toLowerCase().includes(query.toLowerCase()),
+ )
+
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+
+
+ {filteredOptions.map(option => (
+
+ {({ active, selected }) => (
+
+ )}
+
+ ))}
+
+
+
+
+ )
+}
+
+interface CheckboxIconProps extends React.SVGProps {
+ checked: boolean
+}
+function CheckboxIcon({ checked, ...rest }: CheckboxIconProps) {
+ return (
+
+ )
+}
+
+function FilterComboboxOption({
+ active,
+ selected,
+ option,
+}: {
+ active: boolean
+ selected: boolean
+ option: string
+}) {
+ return (
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/_components/schedule-list.tsx b/src/app/conf/2025/schedule/_components/schedule-list.tsx
new file mode 100644
index 0000000000..7ee1eff890
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/schedule-list.tsx
@@ -0,0 +1,226 @@
+"use client"
+
+import { format, parseISO, compareAsc } from "date-fns"
+import { ReactElement, useEffect, useState } from "react"
+
+import { Filters, ResetFiltersButton } from "./filters"
+import {
+ type ScheduleSession,
+ CategoryName,
+ ConcurrentSessions,
+ ScheduleSessionsByDay,
+} from "./session-list"
+import { ScheduleSessionCard } from "./schedule-session-card"
+
+function getSessionsByDay(
+ scheduleData: ScheduleSession[],
+ initialFilter:
+ | ((sessions: ScheduleSession[]) => ScheduleSession[])
+ | undefined,
+ filters: Record,
+) {
+ const data = initialFilter ? initialFilter(scheduleData) : scheduleData
+ const filteredSortedSchedule = (data || []).sort((a, b) =>
+ compareAsc(new Date(a.event_start), new Date(b.event_start)),
+ )
+
+ const concurrentSessions: ConcurrentSessions = {}
+ filteredSortedSchedule.forEach(session => {
+ const audienceFilter = filters.Audience
+ const talkCategoryFilter = filters["Talk category"]
+ const eventTypeFilter = filters["Event type"]
+
+ let include = true
+ if (audienceFilter.length > 0) {
+ include = include && audienceFilter.includes((session as any).company)
+ }
+ if (talkCategoryFilter.length > 0) {
+ include = include && talkCategoryFilter.includes(session.event_type)
+ }
+ if (eventTypeFilter.length > 0) {
+ include = include && eventTypeFilter.includes(session.audience)
+ }
+
+ if (!include) {
+ return
+ }
+
+ if (!concurrentSessions[session.event_start]) {
+ concurrentSessions[session.event_start] = []
+ }
+ concurrentSessions[session.event_start].push(session)
+ })
+
+ const sessionsByDay: ScheduleSessionsByDay = {}
+ Object.entries(concurrentSessions).forEach(([date, sessions]) => {
+ const day = date.split(" ")[0]
+ if (!sessionsByDay[day]) {
+ sessionsByDay[day] = {}
+ }
+ sessionsByDay[day] = {
+ ...sessionsByDay[day],
+ [date]: sessions.sort((a, b) =>
+ (a?.venue ?? "").localeCompare(b?.venue ?? ""),
+ ),
+ }
+ })
+
+ return sessionsByDay
+}
+
+interface Props {
+ showEventType?: boolean
+ showFilter?: boolean
+ scheduleData: ScheduleSession[]
+ filterSchedule?: (sessions: ScheduleSession[]) => ScheduleSession[]
+ year: "2025" | "2024"
+ eventsColors: Record
+ filterCategories: {
+ name: CategoryName
+ options: string[]
+ }[]
+}
+
+export function ScheduleList({
+ showEventType,
+ showFilter = true,
+ filterSchedule,
+ scheduleData,
+ year,
+ eventsColors,
+ filterCategories,
+}: Props): ReactElement {
+ const [filtersState, setFiltersState] = useState<
+ Record
+ >({
+ Audience: [],
+ "Talk category": [],
+ "Event type": [],
+ })
+ const [sessionsState, setSessionState] = useState(
+ () => {
+ return getSessionsByDay(scheduleData, filterSchedule, filtersState)
+ },
+ )
+
+ useEffect(() => {
+ setSessionState(
+ getSessionsByDay(scheduleData, filterSchedule, filtersState),
+ )
+ }, [filtersState, scheduleData])
+
+ return (
+ <>
+
+
+
+ setFiltersState({
+ Audience: [],
+ "Talk category": [],
+ "Event type": [],
+ })
+ }
+ />
+
+ {showFilter && (
+ {
+ setFiltersState(prev => ({
+ ...prev,
+ [category]: newSelectedOptions,
+ }))
+ }}
+ />
+ )}
+ {Object.entries(sessionsState).length === 0 ? (
+
+
No sessions found
+
+ ) : (
+ <>
+
+ {/* Skip registeration prior day for graphql conf 2024 */}
+ {Object.keys(sessionsState)
+ .slice(year === "2024" ? 1 : 0)
+ .map((date, index) => (
+
+ Day {index + 1}
+
+ ))}
+
+ {Object.entries(sessionsState).map(
+ ([date, concurrentSessionsGroup], index) => (
+
+
+ {format(parseISO(date), "EEEE, MMMM d")}
+
+ {Object.entries(concurrentSessionsGroup).map(
+ ([sessionDate, sessions]) => (
+
+
+
+
+ {format(parseISO(sessionDate), "hh:mmaaaa 'PDT'")}
+
+
+
+ {sessions.map(session => (
+
+ ))}
+
+
+
+ ),
+ )}
+
+ ),
+ )}
+ >
+ )}
+ >
+ )
+}
+
+function BookmarkOnSched() {
+ return (
+
+ Bookmark sessions & plan your days on Sched
+
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/_components/schedule-session-card.tsx b/src/app/conf/2025/schedule/_components/schedule-session-card.tsx
new file mode 100644
index 0000000000..7a4684015e
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/schedule-session-card.tsx
@@ -0,0 +1,78 @@
+import { getEventTitle } from "@/app/conf/2023/utils"
+import { SchedSpeaker } from "@/app/conf/2023/types"
+
+import { PinIcon } from "../../pixelarticons/pin-icon"
+import { Tag } from "@/app/conf/_design-system/tag"
+
+import { type ScheduleSession } from "./session-list"
+
+function isString(x: any) {
+ return Object.prototype.toString.call(x) === "[object String]"
+}
+
+export function ScheduleSessionCard({
+ session,
+ showEventType,
+ year,
+ eventsColors,
+}: {
+ session: ScheduleSession
+ showEventType: boolean | undefined
+ year: "2025" | "2024"
+ eventsColors: Record
+}) {
+ const eventType = session.event_type.endsWith("s")
+ ? session.event_type.slice(0, -1)
+ : session.event_type
+
+ const speakers = session.speakers
+ const formattedSpeakers = isString(speakers || [])
+ ? (speakers as string)?.split(",")
+ : (speakers as SchedSpeaker[])?.map(e => e.name)
+
+ const eventTitle = getEventTitle(
+ // @ts-expect-error fixme
+ session,
+ formattedSpeakers,
+ )
+
+ const eventColor = eventsColors[session.event_type]
+
+ return session.event_type === "Breaks" ? (
+
+ {showEventType ? eventType + " / " : ""}
+ {eventTitle}
+
+ ) : (
+
+
+ {eventColor && (
+
+ {eventType}
+
+ )}
+
+ {showEventType ? eventType + " / " : ""}
+ {eventTitle}
+
+ {(speakers?.length || 0) > 0 && (
+
+ {/* todo: link to speakers (anchor background on z-index above the main link layer) */}
+ {formattedSpeakers.join(", ")}
+
+ )}
+
+
+ {session.venue}
+
+
+
+
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/_components/session-list.tsx b/src/app/conf/2025/schedule/_components/session-list.tsx
new file mode 100644
index 0000000000..971cb9ade0
--- /dev/null
+++ b/src/app/conf/2025/schedule/_components/session-list.tsx
@@ -0,0 +1,274 @@
+"use client"
+
+import { compareAsc } from "date-fns"
+import { clsx } from "clsx"
+import NextLink from "next/link"
+import { ComponentProps, ReactElement, useEffect, useState } from "react"
+
+import { SchedSpeaker } from "@/app/conf/2023/types"
+
+import { Filters } from "./filters"
+
+export interface ScheduleSession {
+ id: string
+ audience: string
+ description: string
+ event_end: string
+ event_start: string
+ event_subtype: string
+ event_type: string
+ name: string
+ venue?: string
+ speakers?: SchedSpeaker[] | string
+ files?: { name: string; path: string }[]
+}
+
+export interface ConcurrentSessions {
+ [date: string]: ScheduleSession[]
+}
+
+export interface ScheduleSessionsByDay {
+ [date: string]: ConcurrentSessions
+}
+
+export type CategoryName = "Audience" | "Talk category" | "Event type"
+
+function getSessionsByDay(
+ scheduleData: ScheduleSession[],
+ initialFilter:
+ | ((sessions: ScheduleSession[]) => ScheduleSession[])
+ | undefined,
+ filters: Record,
+) {
+ const data = initialFilter ? initialFilter(scheduleData) : scheduleData
+ const filteredSortedSchedule = (data || []).sort((a, b) =>
+ compareAsc(new Date(a.event_start), new Date(b.event_start)),
+ )
+
+ const concurrentSessions: ConcurrentSessions = {}
+ filteredSortedSchedule.forEach(session => {
+ const audienceFilter = filters.Audience
+ const talkCategoryFilter = filters["Talk category"]
+ const eventTypeFilter = filters["Event type"]
+
+ let include = true
+ if (audienceFilter.length > 0) {
+ include = include && audienceFilter.includes(session.audience)
+ }
+ if (talkCategoryFilter.length > 0) {
+ include = include && talkCategoryFilter.includes(session.event_subtype)
+ }
+ if (eventTypeFilter.length > 0) {
+ include = include && eventTypeFilter.includes(session.event_type)
+ }
+
+ if (!include) {
+ return
+ }
+
+ if (!concurrentSessions[session.event_start]) {
+ concurrentSessions[session.event_start] = []
+ }
+ concurrentSessions[session.event_start].push(session)
+ })
+
+ const sessionsByDay: ScheduleSessionsByDay = {}
+ Object.entries(concurrentSessions).forEach(([date, sessions]) => {
+ const day = date.split(" ")[0]
+ if (!sessionsByDay[day]) {
+ sessionsByDay[day] = {}
+ }
+ sessionsByDay[day] = {
+ ...sessionsByDay[day],
+ [date]: sessions.sort((a, b) =>
+ (a?.venue ?? "").localeCompare(b?.venue ?? ""),
+ ),
+ }
+ })
+
+ return sessionsByDay
+}
+
+interface Props {
+ showFilter?: boolean
+ scheduleData: ScheduleSession[]
+ filterSchedule?: (sessions: ScheduleSession[]) => ScheduleSession[]
+ year: "2023" | "2024"
+ filterCategories: {
+ name: CategoryName
+ options: string[]
+ }[]
+ eventsColors: Record
+ minimalVersion?: boolean
+}
+
+export function SessionList({
+ showFilter = true,
+ filterSchedule,
+ scheduleData,
+ year,
+ filterCategories,
+ eventsColors,
+ minimalVersion,
+}: Props): ReactElement {
+ const [filtersState, setFiltersState] = useState<
+ Record
+ >({
+ Audience: [],
+ "Talk category": [],
+ "Event type": [],
+ })
+ const [sessionsState, setSessionState] = useState(() =>
+ getSessionsByDay(scheduleData, filterSchedule, filtersState),
+ )
+
+ useEffect(() => {
+ setSessionState(
+ getSessionsByDay(scheduleData, filterSchedule, filtersState),
+ )
+ }, [filtersState, scheduleData])
+
+ return (
+
+ {showFilter && (
+
{
+ setFiltersState(prev => ({
+ ...prev,
+ [category]: newSelectedOptions,
+ }))
+ }}
+ />
+ )}
+ {Object.entries(sessionsState).length === 0 ? (
+
+
No sessions found
+
+ ) : (
+
+ {Object.entries(sessionsState).flatMap(
+ ([, concurrentSessionsGroup]) =>
+ Object.entries(concurrentSessionsGroup).flatMap(([, sessions]) =>
+ sessions.flatMap(session => {
+ const speakers = session.speakers as SchedSpeaker[]
+
+ const borderColor = eventsColors[session.event_type]
+
+ return (
+
+
+
+ {year !== "2024" && (
+
+ )}
+
+
+ {(Number(new Date(session.event_end)) -
+ Number(new Date(session.event_start))) /
+ 1000 /
+ 60}
+ m
+
+
+
+ ↗
+
+
*>&]:opacity-100",
+ )}
+ >
+ ▶
+
+
+
+
+ {session.event_type}
+
+
{session.name}
+
+ {speakers.map(s => (
+
+

+
+ {s.name}
+ {s.company}
+
+
+ ))}
+
+
+
+ )
+ }),
+ ),
+ )}
+
+ )}
+
+ )
+}
+
+function ClockIcon(props: ComponentProps<"svg">) {
+ return (
+
+ )
+}
diff --git a/src/app/conf/2025/schedule/page.tsx b/src/app/conf/2025/schedule/page.tsx
new file mode 100644
index 0000000000..a446e36d35
--- /dev/null
+++ b/src/app/conf/2025/schedule/page.tsx
@@ -0,0 +1,38 @@
+import { Metadata } from "next"
+
+import { schedule } from "../_data"
+import { ScheduleList } from "./_components/schedule-list"
+import { filterCategories2024 } from "./_components/filter-categories"
+import { eventsColors } from "../utils"
+import { Button } from "../../_design-system/button"
+import { GET_TICKETS_LINK } from "../links"
+import { Hero } from "../components/hero"
+
+const year = "2025"
+
+export const metadata: Metadata = {
+ title: "Schedule",
+}
+
+export default function SchedulePage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/conf/2025/speakers/[id]/page.tsx b/src/app/conf/2025/speakers/[id]/page.tsx
new file mode 100644
index 0000000000..cd623f356e
--- /dev/null
+++ b/src/app/conf/2025/speakers/[id]/page.tsx
@@ -0,0 +1,118 @@
+import { Metadata } from "next"
+import NextLink from "next/link"
+import React from "react"
+
+import { SessionList } from "@/app/conf/_components/schedule/session-list"
+import {
+ SocialMediaIcon,
+ SocialMediaIconServiceType,
+} from "@/app/conf/_components/speakers/social-media"
+import { Avatar } from "@/app/conf/_components/speakers/avatar"
+import { ChevronLeftIcon } from "@/icons"
+
+import { speakers, schedule } from "../../_data"
+import { metadata as layoutMetadata } from "../../layout"
+
+import { filterCategories2024 } from "@/app/conf/_components/schedule/filter-categories"
+import { eventsColors } from "../../utils"
+import { BackLink } from "../../schedule/_components/back-link"
+
+type SpeakerProps = { params: { id: string } }
+
+export function generateMetadata({ params }: SpeakerProps): Metadata {
+ const decodedId = decodeURIComponent(params.id)
+ const speaker = speakers.find(s => s.username === decodedId)!
+
+ const keywords = [speaker.name, speaker.company, speaker.position].filter(
+ Boolean,
+ ) as string[]
+
+ return {
+ title: speaker.name,
+ description: speaker.about,
+ keywords: [...layoutMetadata.keywords, ...keywords],
+ openGraph: {
+ images: `/img/__og-image/2024/${speaker.username}.png`,
+ },
+ }
+}
+
+export function generateStaticParams() {
+ return speakers.map(s => ({ id: s.username }))
+}
+
+export default function SpeakerPage({ params }: SpeakerProps) {
+ const decodedId = decodeURIComponent(params.id)
+ const speaker = speakers.find(s => s.username === decodedId)!
+
+ const s = schedule
+ .filter(s => s.speakers && s.speakers.some(s => s.username === decodedId))
+ .map(s => ({
+ ...s,
+ speakers: s.speakers!.map(
+ s => speakers.find(speaker => speaker.username === s.username)!,
+ ),
+ }))
+
+ return (
+
+
+
+
+
+
+
+
+
{speaker.name}
+
+
+ {speaker.company}
+ {speaker.company && ", "}
+ {speaker.position}
+
+
+
{speaker.about}
+
+ {!!speaker.socialurls?.length && (
+
+
+ {speaker.socialurls.map(social => (
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
Sessions
+
+
+
+
+
+ )
+}
diff --git a/src/app/conf/2025/speakers/page.tsx b/src/app/conf/2025/speakers/page.tsx
new file mode 100644
index 0000000000..45cf70722f
--- /dev/null
+++ b/src/app/conf/2025/speakers/page.tsx
@@ -0,0 +1,35 @@
+import { Metadata } from "next"
+import { speakers } from "../_data"
+import { Speaker } from "@/app/conf/_components/speakers/speaker"
+import { Hero } from "../components/hero"
+import { Button } from "../../_design-system/button"
+import { GET_TICKETS_LINK } from "../links"
+
+export const metadata: Metadata = {
+ title: "Speakers",
+}
+
+export default function Page() {
+ const year = "2025"
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {speakers.map(speaker => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/conf/2025/utils.ts b/src/app/conf/2025/utils.ts
new file mode 100644
index 0000000000..caa0e76a5f
--- /dev/null
+++ b/src/app/conf/2025/utils.ts
@@ -0,0 +1,19 @@
+export const eventsColors: Record = {
+ Breaks: "#7DAA5E",
+ "Keynote Sessions": "#7e66cc",
+ "Lightning Talks": "#1a5b77",
+ "Session Presentations": "#5c2e75",
+ Workshops: "#4b5fc0",
+ Unconference: "#7e66cc",
+ "API Platform": "#4e6e82",
+ Backend: "#36C1A0",
+ "Breaks & Special Events": "#7DAA5E",
+ "Defies Categorization": "#894545",
+ "Developer Experience": "#6fc9af",
+ "Federation and Composite Schemas": "#cbc749",
+ "GraphQL Clients": "#ca78fc",
+ "GraphQL in Production": "#e4981f",
+ "GraphQL Security": "#CC6BB0",
+ "GraphQL Spec": "#6B73CC",
+ Scaling: "#8D8D8D",
+}
diff --git a/src/app/conf/_design-system/button.tsx b/src/app/conf/_design-system/button.tsx
index 78957123e3..7d86d2d490 100644
--- a/src/app/conf/_design-system/button.tsx
+++ b/src/app/conf/_design-system/button.tsx
@@ -2,7 +2,7 @@ import { clsx } from "clsx"
import { Anchor } from "./anchor"
type Size = "md" | "lg"
-type Variant = "primary" | "secondary"
+type Variant = "primary" | "secondary" | "tertiary"
// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace ButtonProps {
@@ -57,7 +57,7 @@ export type ButtonProps =
export function Button(props: ButtonProps) {
const className = clsx(
- "relative flex items-center justify-center gap-2.5 font-normal text-base/none text-neu-0 bg-neu-900 hover:bg-neu-800 active:bg-neu-700 font-sans h-14 px-8 data-[size=md]:h-12 data-[variant=secondary]:bg-neu-100 data-[variant=secondary]:text-neu-900 data-[variant=secondary]:hover:bg-neu-200/75 data-[variant=secondary]:active:bg-neu-200/90 gql-focus-visible [aria-disabled]:bg-neu-800 aria-disabled:pointer-events-none",
+ "relative flex items-center justify-center gap-2.5 font-normal text-base/none text-neu-0 bg-neu-900 hover:bg-neu-800 active:bg-neu-700 font-sans h-14 px-8 data-[size=md]:h-12 data-[variant=secondary]:bg-neu-100 data-[variant=secondary]:text-neu-900 data-[variant=secondary]:hover:bg-neu-200/75 data-[variant=secondary]:active:bg-neu-200/90 data-[variant=tertiary]:bg-neu-100 data-[variant=tertiary]:text-neu-900 data-[variant=tertiary]:hover:bg-neu-200 data-[variant=tertiary]:active:bg-neu-300 gql-focus-visible [aria-disabled]:bg-neu-800 aria-disabled:pointer-events-none dark:data-[variant=tertiary]:bg-neu-900/10 dark:data-[variant=tertiary]:text-neu-900 dark:data-[variant=tertiary]:hover:bg-neu-900/[.125] dark:data-[variant=tertiary]:active:bg-neu-800/20 dark:data-[variant=tertiary]:ring-1 dark:data-[variant=tertiary]:ring-inset dark:data-[variant=tertiary]:ring-neu-900/20",
props.className,
)
diff --git a/src/app/conf/_design-system/tag.tsx b/src/app/conf/_design-system/tag.tsx
new file mode 100644
index 0000000000..efc22e651e
--- /dev/null
+++ b/src/app/conf/_design-system/tag.tsx
@@ -0,0 +1,25 @@
+import clsx from "clsx"
+
+export interface TagProps extends React.HTMLAttributes {
+ color: string
+}
+export function Tag({ color, children, style, className, ...rest }: TagProps) {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 639673a139..5dbf07df44 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -73,11 +73,31 @@ const config: Config = {
animation: {
scroll:
"scroll var(--animation-duration, 40s) var(--animation-direction, forwards) linear infinite",
+ "arrow-left":
+ "arrow-left var(--animation-duration, .75s) var(--animation-direction, forwards) ease infinite",
+ "show-overflow":
+ "show-overflow var(--animation-duration, 12s) var(--animation-delay, 1s) var(--animation-direction, forwards) ease infinite",
},
keyframes: {
scroll: {
to: {
- transform: "translate(calc(-50% - .5rem))",
+ transform: "translate(calc(-50% - .25rem))",
+ },
+ },
+ "arrow-left": {
+ "0%, 100%": {
+ transform: "translateX(0)",
+ },
+ "50%": {
+ transform: "translateX(var(--arrow-left-x,-1.5px))",
+ },
+ },
+ "show-overflow": {
+ "0%, 100%": {
+ transform: "translateX(0)",
+ },
+ "25%, 75%": {
+ transform: "translateX(var(--delta-x))",
},
},
},
@@ -86,9 +106,9 @@ const config: Config = {
plugins: [
typography,
containerQueries,
- plugin(({ addUtilities }) => {
+ plugin(({ addBase }) => {
// heading styles
- addUtilities({
+ addBase({
".typography-d1, .typography-h1, .typography-h2, .typography-h3": {
lineHeight: "1.2",
},
@@ -119,7 +139,7 @@ const config: Config = {
})
// paragraph styles
- addUtilities({
+ addBase({
".typography-body-lg, .typography-body-md, .typography-body-sm, .typography-body-xs":
{
lineHeight: "1.5",
@@ -151,7 +171,7 @@ const config: Config = {
})
// other text styles
- addUtilities({
+ addBase({
".typography-button, .typography-tagline": {
fontSize: "16px",
lineHeight: "1",
@@ -167,7 +187,7 @@ const config: Config = {
},
})
- addUtilities({
+ addBase({
".typography-link": {
color: "theme('colors.neu-800')",
textDecoration: "underline",