Skip to content

Commit 509573c

Browse files
committed
Draft new Speakers and Schedule pages
1 parent a1e4c1f commit 509573c

17 files changed

+1378
-14
lines changed

src/app/conf/2025/_data.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import "server-only"
2+
import { stripHtml } from "string-strip-html"
3+
import { SchedSpeaker, ScheduleSession } from "@/app/conf/2023/types"
4+
import pLimit from "p-limit"
5+
6+
async function fetchData<T>(url: string): Promise<T> {
7+
try {
8+
const response = await fetch(url, {
9+
method: "POST",
10+
headers: {
11+
"Content-Type": "application/json",
12+
"User-Agent": "GraphQL Conf / GraphQL Foundation",
13+
},
14+
})
15+
const data = await response.json()
16+
return data
17+
} catch (error) {
18+
throw new Error(
19+
`Error fetching data from ${url}: ${(error as Error).message || (error as Error).toString()}`,
20+
)
21+
}
22+
}
23+
24+
const token = process.env.SCHED_ACCESS_TOKEN_2024
25+
26+
async function getUsernames(): Promise<string[]> {
27+
const response = await fetchData<{ username: string }[]>(
28+
`https://graphqlconf2024.sched.com/api/user/list?api_key=${token}&format=json&fields=username`,
29+
)
30+
return response.map(user => user.username)
31+
}
32+
33+
const limit = pLimit(40) // rate limit is 30req/min
34+
35+
async function getSpeakers(): Promise<SchedSpeaker[]> {
36+
const usernames = await getUsernames()
37+
38+
const users = await Promise.all(
39+
usernames.map(username =>
40+
limit(() => {
41+
return fetchData<SchedSpeaker>(
42+
`https://graphqlconf2024.sched.com/api/user/get?api_key=${token}&by=username&term=${username}&format=json&fields=username,company,position,name,about,location,url,avatar,role,socialurls`,
43+
)
44+
}),
45+
),
46+
)
47+
48+
const result = users
49+
.filter(speaker => speaker.role.includes("speaker"))
50+
.map(user => {
51+
return {
52+
...user,
53+
about: stripHtml(user.about).result,
54+
}
55+
})
56+
57+
return result
58+
}
59+
60+
async function getSchedule(): Promise<ScheduleSession[]> {
61+
const sessions = await fetchData<ScheduleSession[]>(
62+
`https://graphqlconf2024.sched.com/api/session/export?api_key=${token}&format=json`,
63+
)
64+
65+
const result = sessions.map(session => {
66+
const { description } = session
67+
if (description?.includes("<")) {
68+
// console.log(`Found HTML element in about field for session "${session.name}"`)
69+
}
70+
71+
return {
72+
...session,
73+
description: description && stripHtml(description).result,
74+
}
75+
})
76+
77+
return result
78+
}
79+
80+
// @ts-expect-error -- fixme
81+
export const speakers = await getSpeakers()
82+
83+
// @ts-expect-error -- fixme
84+
export const schedule = await getSchedule()

src/app/conf/2025/_videos.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const videos: {
2+
id: string
3+
title: string
4+
}[] = [
5+
// temporary
6+
{
7+
id: "fA81OFu9BVY",
8+
title: `Top 10 GraphQL Security Checks for Every Developer - Ankita Gupta, Ankush Jain - Akto.io`,
9+
},
10+
]

src/app/conf/2025/components/hero/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function Hero() {
1414
return (
1515
<article className="gql-conf-navbar-strip relative isolate flex flex-col justify-center bg-pri-base text-neu-0 selection:bg-blk/40 before:bg-white/30 dark:bg-pri-darker dark:text-neu-900 dark:selection:bg-white/40 before:dark:bg-blk/40">
1616
<article className="relative">
17-
<Stripes />
17+
<HeroStripes />
1818
<div className="gql-conf-container mx-auto flex max-w-full flex-col gap-12 overflow-hidden p-4 pt-6 sm:p-8 sm:pt-12 md:gap-12 md:bg-left md:p-12 lg:px-24 lg:pb-16 lg:pt-24">
1919
<div className="flex gap-10 max-md:flex-col md:justify-between">
2020
<h1 className="flex flex-wrap gap-2 typography-d1">
@@ -35,7 +35,7 @@ export function Hero() {
3535
</div>
3636

3737
<div className="flex flex-col gap-8">
38-
<DateAndLocation />
38+
<HeroDateAndLocation />
3939
<Button className="md:w-fit" href={GET_TICKETS_LINK}>
4040
Get your tickets
4141
</Button>
@@ -55,7 +55,7 @@ export function Hero() {
5555
)
5656
}
5757

58-
function DateAndLocation() {
58+
export function HeroDateAndLocation() {
5959
return (
6060
<div className="flex flex-col gap-4 typography-body-md md:flex-row md:gap-6">
6161
<div className="flex items-center gap-2">
@@ -77,7 +77,7 @@ const maskEven =
7777
const maskOdd =
7878
"repeating-linear-gradient(to right, black, black 12px, transparent 12px, transparent 24px)"
7979

80-
function Stripes() {
80+
export function HeroStripes() {
8181
return (
8282
<ImageLoaded
8383
role="presentation"

src/app/conf/2025/components/image-loaded.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,34 @@
33
import type { StaticImageData } from "next/image"
44
import { useEffect, useState } from "react"
55

6+
const _cache = new Map<string, HTMLImageElement>()
7+
68
export interface ImageLoadedProps extends React.HTMLAttributes<HTMLDivElement> {
79
image: string | StaticImageData
810
}
911

1012
export function ImageLoaded({ image, ...rest }: ImageLoadedProps) {
1113
const [loaded, setLoaded] = useState(false)
14+
const src = typeof image === "string" ? image : image.src
15+
16+
const alreadyLoaded = _cache.get(src)?.complete
1217

1318
useEffect(() => {
14-
const img = new Image()
15-
const src = typeof image === "string" ? image : image.src
16-
img.src = src
17-
img.onload = () => setLoaded(true)
18-
}, [image])
19+
let img: HTMLImageElement
20+
if (_cache.has(src)) {
21+
img = _cache.get(src)!
22+
if (img.complete) {
23+
setLoaded(true)
24+
} else {
25+
img.addEventListener("load", () => setLoaded(true))
26+
}
27+
} else {
28+
img = new Image()
29+
img.src = src
30+
img.addEventListener("load", () => setLoaded(true))
31+
_cache.set(src, img)
32+
}
33+
}, [src])
1934

20-
return <div data-loaded={loaded} {...rest} />
35+
return <div data-loaded={alreadyLoaded || loaded} {...rest} />
2136
}

src/app/conf/2025/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ export default function Layout({
4040
<Navbar
4141
year={2025}
4242
links={[
43-
{ children: "Sponsor", href: "/conf/2025/#sponsors" },
44-
{ children: "Submit to Speak", href: "/conf/2025/#speakers" },
45-
{ children: "Register", href: "/conf/2025/#register" },
43+
{ children: "Schedule", href: "/conf/2025/schedule" },
44+
{ children: "Speakers", href: "/conf/2025/speakers" },
45+
{ children: "Sponsors", href: "/conf/2025/#sponsors" },
4646
{ children: "Recap", href: "/conf/2024" },
4747
{ children: "Resources", href: "/conf/2025/resources" },
4848
{ children: "FAQ", href: "/conf/2025/#faq" },
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { notFound } from "next/navigation"
2+
import { Metadata } from "next"
3+
import clsx from "clsx"
4+
import { format, parseISO } from "date-fns"
5+
6+
import { metadata as layoutMetadata } from "@/app/conf/2023/layout"
7+
import { Avatar } from "../../../_components/speakers/avatar"
8+
import {
9+
SocialMediaIcon,
10+
SocialMediaIconServiceType,
11+
} from "../../../_components/speakers/social-media"
12+
import { speakers, schedule } from "../../_data"
13+
import { ScheduleSession } from "../../../2023/types"
14+
15+
import { SessionVideo } from "./session-video"
16+
import { NavbarPlaceholder } from "../../components/navbar"
17+
import { BackLink } from "../_components/back-link"
18+
19+
function getEventTitle(event: ScheduleSession, speakers: string[]): string {
20+
let { name } = event
21+
22+
if (!speakers) {
23+
return name
24+
}
25+
26+
speakers?.forEach(speaker => {
27+
const speakerInTitle = name.indexOf(`- ${speaker.replace("ı", "i")}`)
28+
if (speakerInTitle > -1) {
29+
name = name.slice(0, speakerInTitle)
30+
}
31+
})
32+
33+
return name
34+
}
35+
36+
type SessionProps = { params: { id: string } }
37+
38+
export function generateMetadata({ params }: SessionProps): Metadata {
39+
const event = schedule.find(s => s.id === params.id)!
40+
41+
const keywords = [
42+
event.event_type,
43+
event.audience,
44+
event.event_subtype,
45+
...(event.speakers || []).map(s => s.name),
46+
].filter(Boolean)
47+
48+
return {
49+
title: event.name,
50+
description: event.description,
51+
keywords: [...layoutMetadata.keywords, ...keywords],
52+
openGraph: {
53+
images: `/img/__og-image/2024/${event.id}.png`,
54+
},
55+
}
56+
}
57+
58+
export function generateStaticParams() {
59+
return schedule.filter(s => s.id).map(s => ({ id: s.id }))
60+
}
61+
62+
const Tag = ({
63+
text,
64+
featured = false,
65+
}: {
66+
text: string
67+
featured?: boolean
68+
}) =>
69+
!text ? null : (
70+
<span
71+
className={clsx(
72+
"h-max whitespace-nowrap rounded-full border border-solid px-3 py-1 typography-tagline",
73+
featured && "border-2 border-pri-darker bg-pri-darker text-white",
74+
)}
75+
>
76+
{text}
77+
</span>
78+
)
79+
80+
export default function SessionPage({ params }: SessionProps) {
81+
const event = schedule.find(s => s.id === params.id)
82+
if (!event) {
83+
notFound()
84+
}
85+
86+
// @ts-expect-error -- fixme
87+
event.speakers = (event.speakers || []).map(speaker =>
88+
speakers.find(s => s.username === speaker.username),
89+
)
90+
91+
const eventType = event.event_type.endsWith("s")
92+
? event.event_type.slice(0, -1)
93+
: event.event_type
94+
95+
const eventTitle = getEventTitle(
96+
event,
97+
event.speakers!.map(s => s.name),
98+
)
99+
100+
return (
101+
<main className="gql-all-anchors-focusable">
102+
<NavbarPlaceholder className="top-0 bg-neu-0 before:bg-white/40 dark:bg-pri-darker dark:before:bg-black/30" />
103+
<div className="gql-conf-container gql-conf-navbar-strip text-neu-900 before:bg-white/40 before:dark:bg-blk/30">
104+
<div className="py-10">
105+
<section className="xs:px-0 mx-auto min-h-[80vh] flex-col justify-center px-2 md:container lg:justify-between">
106+
<BackLink year="2025" kind="schedule" />
107+
<SessionVideo event={event} eventTitle={eventTitle} />
108+
109+
<div className="mx-auto mt-10 flex flex-col self-center sm:space-y-4">
110+
<div className="space-y-5">
111+
<div className="flex flex-wrap gap-3">
112+
<Tag text={eventType} featured />
113+
<Tag text={event.audience} />
114+
<Tag text={event.event_subtype} />
115+
</div>
116+
<h1 className="mt-0 typography-h1">{eventTitle}</h1>
117+
<time dateTime={event.event_start} className="mt-4">
118+
{format(
119+
parseISO(event.event_start),
120+
"EEEE, MMMM d / hh:mmaaaa 'PDT'",
121+
)}{" "}
122+
- {format(parseISO(event.event_end), "hh:mmaaaa 'PDT'")}
123+
</time>
124+
</div>
125+
<div className="mt-8 flex flex-col flex-wrap gap-5 lg:flex-row">
126+
{event.speakers!.map(speaker => (
127+
<div
128+
className={`flex w-full items-center gap-3 ${event?.speakers?.length || 0 > 1 ? "max-w-[320px]" : ""}`}
129+
key={speaker.username}
130+
>
131+
<Avatar
132+
className="size-[100px] rounded-full lg:size-[120px]"
133+
avatar={speaker.avatar}
134+
name={speaker.name}
135+
/>
136+
137+
<div className="flex flex-col gap-1.5 lg:gap-1">
138+
<a
139+
href={`/conf/2024/speakers/${speaker.username}`}
140+
className="mt-0 typography-body-lg"
141+
>
142+
{speaker.name}
143+
</a>
144+
145+
<span className="typography-body-sm">
146+
<span>{speaker.company}</span>
147+
{speaker.company && ", "}
148+
{speaker.position}
149+
</span>
150+
{speaker.socialurls?.length ? (
151+
<div className="mt-0 text-[#333333]">
152+
<div className="flex space-x-2">
153+
{speaker.socialurls.map(social => (
154+
<a
155+
key={social.url}
156+
href={social.url}
157+
target="_blank"
158+
rel="noreferrer"
159+
className="flex items-center text-black"
160+
>
161+
<SocialMediaIcon
162+
service={
163+
social.service.toLowerCase() as SocialMediaIconServiceType
164+
}
165+
/>
166+
</a>
167+
))}
168+
</div>
169+
</div>
170+
) : null}
171+
</div>
172+
</div>
173+
))}
174+
</div>
175+
<p>{event.description}</p>
176+
177+
<div className="py-8">
178+
{event.files?.map(({ path }) => (
179+
<div key={path}>
180+
<a href={path} target="_blank" rel="noreferrer">
181+
View Full PDF{" "}
182+
<span className="font-sans text-2xl font-light"></span>
183+
</a>
184+
<iframe src={path} className="aspect-video size-full" />
185+
</div>
186+
))}
187+
</div>
188+
</div>
189+
</section>
190+
</div>
191+
</div>
192+
</main>
193+
)
194+
}

0 commit comments

Comments
 (0)