diff --git a/src/app/directory/[slug]/opengraph-image.tsx b/src/app/directory/[slug]/opengraph-image.tsx index 965da6d61..7737a358d 100644 --- a/src/app/directory/[slug]/opengraph-image.tsx +++ b/src/app/directory/[slug]/opengraph-image.tsx @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { ImageResponse } from 'next/og'; import { UTDClubsLogoStandalone } from '@src/icons/UTDClubsLogo'; import { db } from '@src/server/db'; @@ -20,7 +20,7 @@ export default async function Image({ params }: { params: { slug: string } }) { interBuffer, ] = await Promise.all([ db.query.club.findFirst({ - where: (club) => eq(club.slug, slug), + where: (club) => and(eq(club.slug, slug), eq(club.approved, 'approved')), with: { userMetadataToClubs: { columns: { userId: true }, diff --git a/src/app/events/[id]/opengraph-image.tsx b/src/app/events/[id]/opengraph-image.tsx new file mode 100644 index 000000000..15d491959 --- /dev/null +++ b/src/app/events/[id]/opengraph-image.tsx @@ -0,0 +1,350 @@ +import { + format, + formatDuration, + intervalToDuration, + isSameDay, + type FormatDistanceToken, +} from 'date-fns'; +import { and, eq } from 'drizzle-orm'; +import { ImageResponse } from 'next/og'; +import { UTDClubsLogoStandalone } from '@src/icons/UTDClubsLogo'; +import { db } from '@src/server/db'; +import { addVersionToImage } from '@src/utils/imageCacheBust'; + +export const runtime = 'edge'; +export const alt = 'Event Details'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +const distanceTokenUnits: Partial> = { + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'd', + xMonths: 'mo', + xYears: 'y', +}; + +function formatEventDate(startTime: Date, endTime: Date): string { + const dateStr = format(startTime, 'EEE, LLLL d, yyyy @ h:mm a'); + + if (startTime.getTime() === endTime.getTime()) { + return dateStr; + } + + const duration = formatDuration( + intervalToDuration({ start: startTime, end: endTime }), + { + locale: { + formatDistance: (token, count) => + `${count}${distanceTokenUnits[token] ?? ''}`, + }, + }, + ); + + const endStr = isSameDay(startTime, endTime) + ? format(endTime, 'h:mm a') + : format(endTime, 'EEE, LLLL d, yyyy @ h:mm a'); + + return `${dateStr} · ${duration} (till ${endStr})`; +} + +export default async function Image({ params }: { params: { id: string } }) { + const { id } = await params; + + const [eventData, gradientBuffer, baiJamjureeBuffer, interBuffer] = + await Promise.all([ + db.query.events.findFirst({ + where: (events) => + and(eq(events.id, id), eq(events.status, 'approved')), + with: { + club: { + columns: { name: true, profileImage: true, updatedAt: true }, + }, + }, + }), + fetch( + new URL( + '../../../../public/images/landingGradient.png', + import.meta.url, + ), + ).then((res) => res.arrayBuffer()), + loadGoogleFont('Bai Jamjuree', 700), + loadGoogleFont('Inter', 600), + ]); + + const background = ( + background gradient + ); + + if (!eventData) { + return new ImageResponse( +
+ {background} +

Event Not Found

+
, + { ...size }, + ); + } + + const hasImage = !!eventData.image; + const dateStr = formatEventDate(eventData.startTime, eventData.endTime); + + return new ImageResponse( +
+ {background} + + {/* Left Side (renders if there's an event image) */} + {hasImage && ( +
+
+ {eventData.name +
+
+ )} + + {/* Right Side (Content) */} +
+

+ {eventData.name} +

+ + {/* Date & Time */} +
+ {dateStr} +
+ + {/* Location */} + {eventData.location && ( +
+ {eventData.location} +
+ )} + + {/* Details Container */} +
+ {/* Club name */} + {eventData.club.profileImage && ( +
+ {eventData.club.name +
+ )} +
+ {eventData.club.name} +
+ + {/* Divider */} +
+ + {/* Nebula Logo */} +
+ +
+
+ UTD CLUBS +
+
+
+
, + { + ...size, + fonts: [ + { + name: 'Bai Jamjuree', + data: baiJamjureeBuffer, + style: 'normal', + weight: 700, + }, + { + name: 'Inter', + data: interBuffer, + style: 'normal', + weight: 600, + }, + ], + }, + ); +} + +async function loadGoogleFont(font: string, weight: number) { + const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&display=swap`; + const css = await fetch(url).then((res) => res.text()); + const resource = css.match( + /src: url\((.+)\) format\('(opentype|truetype)'\)/, + ); + + if (!resource) { + throw new Error('Failed to load font'); + } + + return fetch(resource[1]!).then((res) => res.arrayBuffer()); +} diff --git a/src/app/events/[id]/page.tsx b/src/app/events/[id]/page.tsx index e57d32114..99a69e168 100644 --- a/src/app/events/[id]/page.tsx +++ b/src/app/events/[id]/page.tsx @@ -1,3 +1,10 @@ +import { + format, + formatDuration, + intervalToDuration, + isSameDay, + type FormatDistanceToken, +} from 'date-fns'; import { type Metadata } from 'next'; import ClubEventHeader from '@src/components/club/listing/ClubEventHeader'; import EventBody from '@src/components/events/listing/EventBody'; @@ -6,6 +13,15 @@ import { EventHeader } from '@src/components/header/Header'; import { api } from '@src/trpc/server'; import { convertMarkdownToPlaintext } from '@src/utils/markdown'; +const distanceTokenUnits: Partial> = { + xSeconds: 's', + xMinutes: 'm', + xHours: 'h', + xDays: 'd', + xMonths: 'mo', + xYears: 'y', +}; + type Params = { params: Promise<{ id: string }> }; export default async function EventsPage(props: Params) { @@ -38,6 +54,25 @@ export async function generateMetadata(props: { description: 'Event not found', }; + const startStr = format(event.startTime, 'EEE, LLLL d, yyyy @ h:mm a'); + + let durationStr = ''; + if (event.startTime.getTime() !== event.endTime.getTime()) { + const duration = formatDuration( + intervalToDuration({ start: event.startTime, end: event.endTime }), + { + locale: { + formatDistance: (token, count) => + `${count}${distanceTokenUnits[token] ?? ''}`, + }, + }, + ); + const endStr = isSameDay(event.startTime, event.endTime) + ? format(event.endTime, 'h:mm a') + : format(event.endTime, 'EEE, LLLL d, yyyy @ h:mm a'); + durationStr = ` · ${duration} (till ${endStr})`; + } + let cleanDescription = `${event.name} from ${event.club.name} on UTD Clubs`; const textDescription = event.description.replace(/^#+.*$/gm, ''); @@ -53,15 +88,22 @@ export async function generateMetadata(props: { } } + const timeDescription = `${startStr}${durationStr}${event.location ? ` | ${event.location}` : ''}`; + return { - title: `${event.name}`, - description: cleanDescription, + title: event.name, + description: `${timeDescription}. ${cleanDescription}`, alternates: { canonical: `https://clubs.utdnebula.com/events/${event.id}`, }, openGraph: { + title: event.name, url: `https://clubs.utdnebula.com/events/${event.id}`, - description: cleanDescription, + description: `${timeDescription}. ${cleanDescription}`, + type: 'website', + }, + twitter: { + card: 'summary_large_image', }, }; }