diff --git a/.gitignore b/.gitignore index 66b2addeb4..9078e9ce16 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,8 @@ playwright-report/ .pnpm-store/ +# Output of scripts/generate-social-banner.ts +social-banners/ + # Personal, per-developer agent instructions CLAUDE.local.md diff --git a/package.json b/package.json index 95764b83ea..51f2d055fd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "next", "format": "pnpm format:check --write", "format:check": "prettier --cache --check .", + "generate:social-banner": "tsx scripts/generate-social-banner.ts", "lint": "eslint --ignore-path .gitignore .", "lint:docs": "eslint --ignore-path .gitignore src/pages/learn --format stylish", "lint:docs:ci": "eslint --ignore-path .gitignore src/pages/learn --format eslint-formatter-github", @@ -127,7 +128,8 @@ "remark-lint-first-heading-level": "3.1.2", "remark-lint-heading-increment": "3.1.2", "tsx": "^4.19.4", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "yaml": "^2.9.0" }, "browserslist": [ "chrome >0 and last 2.5 years", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3404fc521e..8881c4353b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: version: 2.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@igorkowalczyk/is-browser': specifier: ^5.1.0 - version: 5.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) '@lezer/highlight': specifier: 1.2.3 version: 1.2.3 @@ -76,13 +76,13 @@ importers: version: 138.0.2 '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1)) + version: 0.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) '@tailwindcss/nesting': specifier: 0.0.0-insiders.565cd3e version: 0.0.0-insiders.565cd3e(postcss@8.5.6) '@tailwindcss/typography': specifier: ^0.5.15 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1)) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) arktype: specifier: 2.1.29 version: 2.1.29 @@ -211,7 +211,7 @@ importers: version: 13.6.0 tailwindcss: specifier: ^3.4.17 - version: 3.4.19(tsx@4.21.0)(yaml@2.8.1) + version: 3.4.19(tsx@4.21.0)(yaml@2.9.0) timeago.js: specifier: 4.0.2 version: 4.0.2 @@ -290,7 +290,7 @@ importers: version: 5.2.0(eslint@8.57.1) eslint-plugin-tailwindcss: specifier: 3.18.2 - version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1)) + version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -318,6 +318,9 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 optionalDependencies: playwright: specifier: ^1.54.2 @@ -6708,6 +6711,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -8427,9 +8435,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@igorkowalczyk/is-browser@5.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1))': + '@igorkowalczyk/is-browser@5.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))': dependencies: - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.1) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0) '@img/colour@1.0.0': {} @@ -9107,19 +9115,19 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.1 - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1))': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))': dependencies: - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.1) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0) '@tailwindcss/nesting@0.0.0-insiders.565cd3e(postcss@8.5.6)': dependencies: postcss: 8.5.6 postcss-nested: 5.0.6(postcss@8.5.6) - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.1) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0) '@tanstack/react-virtual@3.14.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -10641,11 +10649,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1)): + eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)): dependencies: fast-glob: 3.3.3 postcss: 8.5.6 - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.1) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0) eslint-scope@7.2.2: dependencies: @@ -12573,7 +12581,7 @@ snapshots: title: 4.0.1 unist-util-remove: 4.0.0 unist-util-visit: 5.0.0 - yaml: 2.8.1 + yaml: 2.9.0 zod: 3.25.76 zod-validation-error: 3.5.3(zod@3.25.76) transitivePeerDependencies: @@ -12935,14 +12943,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 tsx: 4.21.0 - yaml: 2.8.1 + yaml: 2.9.0 postcss-nested@5.0.6(postcss@8.5.6): dependencies: @@ -13898,7 +13906,7 @@ snapshots: tabbable@6.5.0: {} - tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.1): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -13917,7 +13925,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -14137,7 +14145,7 @@ snapshots: vfile-message: 4.0.3 vfile-reporter: 8.1.1 vfile-statistics: 3.0.0 - yaml: 2.8.1 + yaml: 2.9.0 transitivePeerDependencies: - bluebird - supports-color @@ -14528,6 +14536,8 @@ snapshots: yaml@2.8.1: {} + yaml@2.9.0: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 diff --git a/scripts/generate-social-banner.ts b/scripts/generate-social-banner.ts new file mode 100644 index 0000000000..be5434cbcd --- /dev/null +++ b/scripts/generate-social-banner.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env tsx + +/** + * Generates social banners (announcement, one per speaker, CFP) for a + * virtual meetup from a YAML manifest, and adds the event to + * src/app/(main)/community/events/events.ts. Expects a dev/prod server to + * already be running on $URL (default http://localhost:3000) — invoke + * after `pnpm dev` is up, same convention as `pnpm conference-kit`. + * + * Usage: pnpm generate:social-banner path/to/meetup.yaml + * + * Manifest schema: see scripts/meetups/EXAMPLE.yaml. + */ + +import { parseArgs } from "node:util" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" +import prettier from "prettier" +import { parse as parseYaml } from "yaml" + +interface MeetupManifest { + event: { + name: string + slug: string + date: string + location?: string + eventLink: string + host: string + hostLink?: string + } + cfp?: { + deadline?: string + link?: string + } + speakers?: Array<{ + name: string + title?: string + company?: string + photo?: string + talkTitle?: string + }> +} + +const BASE_URL = process.env.URL ?? "http://localhost:3000" +const OUTPUT_DIR = resolve(process.cwd(), "social-banners") +const EVENTS_FILE = resolve( + process.cwd(), + "src/app/(main)/community/events/events.ts", +) +const EVENTS_ARRAY_ANCHOR = "const allEvents: Event[] = [" + +function formatEventDate(iso: string) { + const formatted = new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(iso)) + return `${formatted} UTC` +} + +function formatEventDateOnly(iso: string) { + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(iso)) +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") +} + +async function fetchBanner(type: string, data: unknown) { + const url = `${BASE_URL}/social/meetup-banner?type=${type}&data=${encodeURIComponent(JSON.stringify(data))}` + const response = await fetch(url) + if (!response.ok) { + throw new Error( + `Failed to generate ${type} banner (${response.status}): ${await response.text()}`, + ) + } + return Buffer.from(await response.arrayBuffer()) +} + +function buildEventEntry(event: MeetupManifest["event"]) { + const fields: Record = { + name: event.name, + slug: event.slug, + location: event.location ?? "Zoom", + date: event.date, + eventLink: event.eventLink, + host: event.host, + } + if (event.hostLink) fields.hostLink = event.hostLink + fields.kind = "meetup" + + const body = Object.entries(fields) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(", ") + + return `{ ${body} },` +} + +async function addEventToCommunityEvents(event: MeetupManifest["event"]) { + const source = await readFile(EVENTS_FILE, "utf8") + + if (source.includes(`slug: "${event.slug}"`)) { + console.log( + `[social-banner] events.ts already has slug "${event.slug}", skipping`, + ) + return + } + + const anchorIndex = source.indexOf(EVENTS_ARRAY_ANCHOR) + if (anchorIndex === -1) { + throw new Error( + `Could not find "${EVENTS_ARRAY_ANCHOR}" in ${EVENTS_FILE} — has the file changed shape?`, + ) + } + + const insertAt = anchorIndex + EVENTS_ARRAY_ANCHOR.length + const updated = + source.slice(0, insertAt) + + `\n ${buildEventEntry(event)}` + + source.slice(insertAt) + + const config = await prettier.resolveConfig(EVENTS_FILE) + const formatted = await prettier.format(updated, { + ...config, + filepath: EVENTS_FILE, + }) + + await writeFile(EVENTS_FILE, formatted) + console.log(`[social-banner] added "${event.name}" to events.ts`) +} + +async function main() { + const { positionals } = parseArgs({ allowPositionals: true }) + const manifestPath = positionals[0] + + if (!manifestPath) { + console.error("Usage: pnpm generate:social-banner ") + process.exit(1) + } + + const manifest = parseYaml( + await readFile(resolve(process.cwd(), manifestPath), "utf8"), + ) as MeetupManifest + + const { event, cfp, speakers } = manifest + + for (const field of ["name", "slug", "date", "eventLink", "host"] as const) { + if (!event?.[field]) { + throw new Error(`Manifest is missing required field "event.${field}"`) + } + } + + const location = event.location ?? "Zoom" + const date = formatEventDate(event.date) + const outDir = resolve(OUTPUT_DIR, event.slug) + await mkdir(outDir, { recursive: true }) + + const announcement = await fetchBanner("announcement", { + title: event.name, + date, + location, + host: event.host, + eventLink: event.eventLink, + }) + await writeFile(resolve(outDir, "announcement.png"), announcement) + console.log("[social-banner] wrote announcement.png") + + for (const speaker of speakers ?? []) { + const png = await fetchBanner("speaker", { + name: speaker.name, + title: speaker.title, + company: speaker.company, + photo: speaker.photo, + talkTitle: speaker.talkTitle, + eventName: event.name, + date, + location, + }) + const filename = `speaker-${slugify(speaker.name)}.png` + await writeFile(resolve(outDir, filename), png) + console.log(`[social-banner] wrote ${filename}`) + } + + if (cfp) { + const png = await fetchBanner("cfp", { + eventName: event.name, + date, + location, + deadline: cfp.deadline ? formatEventDateOnly(cfp.deadline) : undefined, + link: cfp.link, + }) + await writeFile(resolve(outDir, "cfp.png"), png) + console.log("[social-banner] wrote cfp.png") + } + + await addEventToCommunityEvents(event) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/meetups/EXAMPLE.yaml b/scripts/meetups/EXAMPLE.yaml new file mode 100644 index 0000000000..80851b1390 --- /dev/null +++ b/scripts/meetups/EXAMPLE.yaml @@ -0,0 +1,33 @@ +# Example manifest for `pnpm generate:social-banner`. Copy this file, +# fill in your event's details, and run: +# +# pnpm generate:social-banner scripts/meetups/.yaml +# +# Requires a dev/prod server running on :3000 (`pnpm dev`) — the script +# fetches banners from the /social/meetup-banner route. +# +# Output: PNGs under ./social-banners//, and a new entry in +# src/app/(main)/community/events/events.ts (skipped if the slug already +# exists there). + +event: + name: "GraphQL Virtual Meetup, September 2026" + slug: "graphql-virtual-meetup-september-2026" # used for the output dir and to dedupe events.ts entries + date: "2026-09-22T17:00:00Z" # ISO 8601; shown in the banner header and events.ts + location: "Zoom" # optional, defaults to "Zoom" + eventLink: "https://guild.host/events/graphql-virtual-meetup-zywc3q" # registration link; encoded into the announcement banner's QR code + host: "GraphQL Foundation" + hostLink: "https://graphql.org" # optional + +# Omit this whole block to skip generating a CFP banner. +cfp: + deadline: "2026-09-15" # optional; date only, no time + link: "https://forms.gle/LqQxRV3YB9mrDMwq6" # encoded into the CFP banner's QR code + +# Omit or leave empty to skip speaker banners. One banner is generated per entry. +speakers: + - name: "Jane Doe" + title: "Staff Engineer" # optional + company: "Acme Corp" # optional + photo: "https://example.com/jane.jpg" # optional + talkTitle: "Federation at Scale" # optional diff --git a/scripts/meetups/graphql-virtual-meetup-september-2026.yaml b/scripts/meetups/graphql-virtual-meetup-september-2026.yaml new file mode 100644 index 0000000000..ac7f96c27e --- /dev/null +++ b/scripts/meetups/graphql-virtual-meetup-september-2026.yaml @@ -0,0 +1,11 @@ +event: + name: "GraphQL Virtual Meetup, September 2026" + slug: "graphql-virtual-meetup-september-2026" + date: "2026-09-22T17:00:00Z" + location: "Zoom" + eventLink: "https://guild.host/events/graphql-virtual-meetup-zywc3q" + host: "GraphQL Foundation" + +cfp: + deadline: "2026-09-15T23:59:00Z" + link: "https://forms.gle/LqQxRV3YB9mrDMwq6" diff --git a/src/app/(main)/community/events/events-list.tsx b/src/app/(main)/community/events/events-list.tsx index 62c6b11966..003c9ddec5 100644 --- a/src/app/(main)/community/events/events-list.tsx +++ b/src/app/(main)/community/events/events-list.tsx @@ -104,7 +104,7 @@ export function categorizeEvent( return "working-group" } } else if ("slug" in event) { - return "conference" + return event.kind ?? "conference" } else { // Uncategorized! if (process.env.NODE_ENV !== "production") { diff --git a/src/app/(main)/community/events/events.ts b/src/app/(main)/community/events/events.ts index 1e35a902ed..a9e85b5699 100644 --- a/src/app/(main)/community/events/events.ts +++ b/src/app/(main)/community/events/events.ts @@ -1,3 +1,5 @@ +import type { EventKind } from "./event-filter-tag" + export * from "./event-card" export interface Event { @@ -9,9 +11,28 @@ export interface Event { eventLink: string host: string hostLink?: string + kind?: EventKind } -export const events: Event[] = [ +const allEvents: Event[] = [ + { + name: "Apollo Summit 2026", + slug: "apollo-summit-2026", + location: "San Francisco", + date: "2026-10-06T17:00:00-07:00", + eventLink: "https://apollosummit.dev/", + host: "Apollo GraphQL", + hostLink: "https://www.apollographql.com", + }, + { + name: "GraphQL Virtual Meetup, September 2026", + slug: "graphql-virtual-meetup-september-2026", + location: "Zoom", + date: "2026-09-22T17:00:00Z", + eventLink: "https://guild.host/events/graphql-virtual-meetup-zywc3q", + host: "GraphQL Foundation", + kind: "meetup", + }, { name: "GraphQL Day at FOST London", slug: "graphql-day-fost-london-2026", @@ -236,4 +257,8 @@ export const events: Event[] = [ host: "True Digital Park", hostLink: "https://www.truedigitalpark.com/", }, -].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) +] + +export const events = allEvents.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), +) diff --git a/src/app/conf/conference-kit/_components/qr-code.tsx b/src/app/conf/conference-kit/_components/qr-code.tsx index bc839a91fd..0115ff44bd 100644 --- a/src/app/conf/conference-kit/_components/qr-code.tsx +++ b/src/app/conf/conference-kit/_components/qr-code.tsx @@ -1,5 +1,3 @@ -"use client" - import QRCode from "qrcode" export interface QRCodeSVGProps { diff --git a/src/app/social/meetup-banner/_components/banner-qr-code.tsx b/src/app/social/meetup-banner/_components/banner-qr-code.tsx new file mode 100644 index 0000000000..ab3f4c378d --- /dev/null +++ b/src/app/social/meetup-banner/_components/banner-qr-code.tsx @@ -0,0 +1,42 @@ +import { QRCodeSVG } from "@/app/conf/conference-kit/_components/qr-code" +import { + colors, + fonts, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" + +const RIGHT_COLUMN_TEXT_WIDTH_PX = 260 + +export function BannerQrCode({ + value, + caption, +}: { + value: string + caption: string +}) { + return ( +
+ + + {caption} + +
+ ) +} diff --git a/src/app/social/meetup-banner/_components/cfp-call-image.tsx b/src/app/social/meetup-banner/_components/cfp-call-image.tsx new file mode 100644 index 0000000000..009451aa09 --- /dev/null +++ b/src/app/social/meetup-banner/_components/cfp-call-image.tsx @@ -0,0 +1,117 @@ +import { OpengraphImageFooter } from "@/app/conf/2026/components/og-images/opengraph-image-footer" +import { + colors, + fonts, + RIGHT_COLUMN_WIDTH_PX, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" +import { BannerQrCode } from "./banner-qr-code" +import { MeetupBannerHeader } from "./meetup-banner-header" +import { SponsoredByApollo } from "./sponsored-by-apollo" + +export interface CfpCallImageProps { + eventName: string + date: string + location: string + deadline?: string + link?: string +} + +export function CfpCallImage({ + eventName, + date, + location, + deadline, + link, +}: CfpCallImageProps) { + return ( +
+ + +
+
+ + Call for Proposals + +

+ Speak at {eventName} +

+ {deadline && ( + + Submissions close {deadline} + + )} +
+ + {link && ( +
+ +
+ )} +
+ + Submit your talk + + +
+ ) +} diff --git a/src/app/social/meetup-banner/_components/meetup-announcement-image.tsx b/src/app/social/meetup-banner/_components/meetup-announcement-image.tsx new file mode 100644 index 0000000000..e9ce49df0f --- /dev/null +++ b/src/app/social/meetup-banner/_components/meetup-announcement-image.tsx @@ -0,0 +1,99 @@ +import { + colors, + fonts, + RIGHT_COLUMN_WIDTH_PX, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" +import { BannerQrCode } from "./banner-qr-code" +import { MeetupBannerHeader } from "./meetup-banner-header" +import { SponsoredByApollo } from "./sponsored-by-apollo" + +export interface MeetupAnnouncementImageProps { + title: string + date: string + location: string + host: string + eventLink: string +} + +export function MeetupAnnouncementImage({ + title, + date, + location, + host, + eventLink, +}: MeetupAnnouncementImageProps) { + return ( +
+ + +
+
+ + {host} + +

+ {title} +

+
+ +
+ +
+
+ + +
+ ) +} diff --git a/src/app/social/meetup-banner/_components/meetup-banner-header.tsx b/src/app/social/meetup-banner/_components/meetup-banner-header.tsx new file mode 100644 index 0000000000..9d55538348 --- /dev/null +++ b/src/app/social/meetup-banner/_components/meetup-banner-header.tsx @@ -0,0 +1,145 @@ +import { CalendarIcon } from "@/app/conf/_design-system/pixelarticons/calendar-icon" +import { PinIcon } from "@/app/conf/_design-system/pixelarticons/pin-icon" + +import { GraphQLLogo } from "@/app/conf/2026/components/graphql-conf-logo-link" +import { + colors, + fonts, + RIGHT_COLUMN_WIDTH_PX, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" + +export const MEETUP_BANNER_HEADER_HEIGHT = 154 + +export function MeetupBannerHeader({ + date, + location, +}: { + date: string + location: string +}) { + return ( +
+
+
+ + / + GraphQL Virtual +
+
+ +
+
+
+ + + {date} + +
+
+ +
+
+ + + {location} + +
+
+
+
+ ) +} diff --git a/src/app/social/meetup-banner/_components/speaker-spotlight-image.tsx b/src/app/social/meetup-banner/_components/speaker-spotlight-image.tsx new file mode 100644 index 0000000000..c59918d231 --- /dev/null +++ b/src/app/social/meetup-banner/_components/speaker-spotlight-image.tsx @@ -0,0 +1,175 @@ +import { normalizeProtocolRelativeUrl } from "@/app/conf/2026/components/og-images/normalize-protocol-relative-url" +import { OpengraphImageFooter } from "@/app/conf/2026/components/og-images/opengraph-image-footer" +import { + colors, + fonts, + RIGHT_COLUMN_WIDTH_PX, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" +import { MeetupBannerHeader } from "./meetup-banner-header" +import { SponsoredByApollo } from "./sponsored-by-apollo" + +export interface SpeakerSpotlightImageProps { + name: string + title?: string + company?: string + photo?: string + talkTitle?: string + eventName: string + date: string + location: string +} + +export function SpeakerSpotlightImage({ + name, + title, + company, + photo, + talkTitle, + eventName, + date, + location, +}: SpeakerSpotlightImageProps) { + const position = [title, company].filter(Boolean).join(" · ") + + return ( +
+ + +
+
+
+ + Speaker Spotlight + + +
+

+ {name} +

+ + {position && ( + + {position} + + )} + + {talkTitle && ( + + “{talkTitle}” + + )} +
+
+ + {eventName} + + +
+ + {photo && ( +
+ +
+
+ )} +
+
+ ) +} diff --git a/src/app/social/meetup-banner/_components/sponsored-by-apollo.tsx b/src/app/social/meetup-banner/_components/sponsored-by-apollo.tsx new file mode 100644 index 0000000000..888878c5ca --- /dev/null +++ b/src/app/social/meetup-banner/_components/sponsored-by-apollo.tsx @@ -0,0 +1,36 @@ +import ApolloWordmark from "@/icons/apollo-wordmark.svg?svgr" +import { + colors, + fonts, +} from "@/app/conf/2026/components/og-images/speaker-opengraph-image" + +export function SponsoredByApollo() { + return ( +
+ + Sponsored by + + +
+ ) +} diff --git a/src/app/social/meetup-banner/route.tsx b/src/app/social/meetup-banner/route.tsx new file mode 100644 index 0000000000..db5f9085af --- /dev/null +++ b/src/app/social/meetup-banner/route.tsx @@ -0,0 +1,49 @@ +import { ImageResponse } from "next/og" + +import { loadFontsForOG } from "@/app/fonts/og/load-fonts-for-og" + +import { + CfpCallImage, + type CfpCallImageProps, +} from "./_components/cfp-call-image" +import { + MeetupAnnouncementImage, + type MeetupAnnouncementImageProps, +} from "./_components/meetup-announcement-image" +import { + SpeakerSpotlightImage, + type SpeakerSpotlightImageProps, +} from "./_components/speaker-spotlight-image" + +const size = { + width: 1200, + height: 630, +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const type = searchParams.get("type") + const rawData = searchParams.get("data") + + if (!rawData) { + throw new Error("Missing `data` query parameter") + } + + const data = JSON.parse(rawData) + const fonts = await loadFontsForOG() + + const image = + type === "announcement" ? ( + + ) : type === "speaker" ? ( + + ) : type === "cfp" ? ( + + ) : null + + if (!image) { + throw new Error(`Unknown banner type: ${type}`) + } + + return new ImageResponse(image, { ...size, fonts }) +} diff --git a/src/icons/apollo-wordmark.svg b/src/icons/apollo-wordmark.svg new file mode 100644 index 0000000000..3de45a667a --- /dev/null +++ b/src/icons/apollo-wordmark.svg @@ -0,0 +1,36 @@ + + + + + + + + +