diff --git a/src/app/conf/2023/page.tsx b/src/app/conf/2023/page.tsx index 5f58405460..c4e160cc40 100644 --- a/src/app/conf/2023/page.tsx +++ b/src/app/conf/2023/page.tsx @@ -24,7 +24,7 @@ export default function ConfPage() { function Hero() { return ( -
+
diff --git a/src/app/conf/2025/_data.ts b/src/app/conf/2025/_data.ts new file mode 100644 index 0000000000..e172f3e519 --- /dev/null +++ b/src/app/conf/2025/_data.ts @@ -0,0 +1,84 @@ +import "server-only" +import { stripHtml } from "string-strip-html" +import { SchedSpeaker, ScheduleSession } from "@/app/conf/2023/types" +import pLimit from "p-limit" + +async function fetchData(url: string): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "GraphQL Conf / GraphQL Foundation", + }, + }) + const data = await response.json() + return data + } catch (error) { + throw new Error( + `Error fetching data from ${url}: ${(error as Error).message || (error as Error).toString()}`, + ) + } +} + +const token = process.env.SCHED_ACCESS_TOKEN_2024 + +async function getUsernames(): Promise { + const response = await fetchData<{ username: string }[]>( + `https://graphqlconf2024.sched.com/api/user/list?api_key=${token}&format=json&fields=username`, + ) + return response.map(user => user.username) +} + +const limit = pLimit(40) // rate limit is 30req/min + +async function getSpeakers(): Promise { + const usernames = await getUsernames() + + const users = await Promise.all( + usernames.map(username => + limit(() => { + return fetchData( + `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`, + ) + }), + ), + ) + + const result = users + .filter(speaker => speaker.role.includes("speaker")) + .map(user => { + return { + ...user, + about: stripHtml(user.about).result, + } + }) + + return result +} + +async function getSchedule(): Promise { + const sessions = await fetchData( + `https://graphqlconf2024.sched.com/api/session/export?api_key=${token}&format=json`, + ) + + const result = sessions.map(session => { + const { description } = session + if (description?.includes("<")) { + // console.log(`Found HTML element in about field for session "${session.name}"`) + } + + return { + ...session, + description: description && stripHtml(description).result, + } + }) + + return result +} + +// @ts-expect-error -- fixme +export const speakers = await getSpeakers() + +// @ts-expect-error -- fixme +export const schedule = await getSchedule() diff --git a/src/app/conf/2025/_videos.ts b/src/app/conf/2025/_videos.ts new file mode 100644 index 0000000000..e8b9f74c8b --- /dev/null +++ b/src/app/conf/2025/_videos.ts @@ -0,0 +1,10 @@ +export const videos: { + id: string + title: string +}[] = [ + // temporary + { + id: "fA81OFu9BVY", + title: `Top 10 GraphQL Security Checks for Every Developer - Ankita Gupta, Ankush Jain - Akto.io`, + }, +] diff --git a/src/app/conf/2025/components/hero/hero-image.tsx b/src/app/conf/2025/components/hero/hero-image.tsx new file mode 100644 index 0000000000..d1c58d7415 --- /dev/null +++ b/src/app/conf/2025/components/hero/hero-image.tsx @@ -0,0 +1,17 @@ +import Image from "next-image-export-optimizer" + +import heroPhoto from "./hero-photo.jpeg" + +export function HeroImage() { + return ( +
+ five speakers at GraphQLConf 2024 +
+ ) +} diff --git a/src/app/conf/2025/components/hero/index.tsx b/src/app/conf/2025/components/hero/index.tsx index 5cac0ba2a3..027bbb6bbb 100644 --- a/src/app/conf/2025/components/hero/index.tsx +++ b/src/app/conf/2025/components/hero/index.tsx @@ -1,61 +1,57 @@ -import Image from "next-image-export-optimizer" - -import { Button } from "../../../_design-system/button" import { CalendarIcon } from "../../pixelarticons/calendar-icon" -import { GET_TICKETS_LINK } from "../../links" import { PinIcon } from "../../pixelarticons/pin-icon" -import graphqlFoundationWordmarkSvg from "../../assets/graphql-foundation-wordmark.svg" +import GraphQLFoundationWordmark from "../../assets/graphql-foundation-wordmark.svg?svgr" import { ImageLoaded } from "../image-loaded" import blurBean from "./blur-bean-cropped.webp" -import heroPhoto from "./hero-photo.jpeg" -export function Hero() { +export function Hero({ + pageName, + year, + children, + bottom, +}: { + pageName?: string + year: string + children: React.ReactNode + bottom?: React.ReactNode +}) { return (
- +
-

- GraphQLConf - 2025 -

+ {pageName ? ( +
+ + GraphQLConf {year} + +

{pageName}

+
+ ) : ( +

+ GraphQLConf + {year} +

+ )}
hosted by - GraphQL Foundation +
-
- - -
+
{children}
-
- five speakers at GraphQLConf 2024 -
+ {bottom}
) } -function DateAndLocation() { +export function HeroDateAndLocation() { return (
@@ -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 }) => ( +
+ + View Full PDF{" "} + + +