Skip to content

Commit 01d53c8

Browse files
committed
feat: add OpenGraph image and enhanced metadata for event pages
Create opengraph-image.tsx for event pages following the club OG pattern. Enhance generateMetadata with event time, location, and twitter card.
1 parent b087616 commit 01d53c8

2 files changed

Lines changed: 355 additions & 3 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { and, eq } from 'drizzle-orm';
2+
import { ImageResponse } from 'next/og';
3+
import { UTDClubsLogoStandalone } from '@src/icons/UTDClubsLogo';
4+
import { db } from '@src/server/db';
5+
import { addVersionToImage } from '@src/utils/imageCacheBust';
6+
7+
export const runtime = 'edge';
8+
export const alt = 'Event Details';
9+
export const size = { width: 1200, height: 630 };
10+
export const contentType = 'image/png';
11+
12+
function formatEventDate(startTime: Date, endTime: Date): string {
13+
const dateOptions: Intl.DateTimeFormatOptions = {
14+
weekday: 'short',
15+
month: 'short',
16+
day: 'numeric',
17+
};
18+
const timeOptions: Intl.DateTimeFormatOptions = {
19+
hour: 'numeric',
20+
minute: '2-digit',
21+
hour12: true,
22+
};
23+
24+
const startDate = startTime.toLocaleDateString('en-US', dateOptions);
25+
const startTimeStr = startTime.toLocaleTimeString('en-US', timeOptions);
26+
const endTimeStr = endTime.toLocaleTimeString('en-US', timeOptions);
27+
28+
const sameDay = startTime.toDateString() === endTime.toDateString();
29+
if (sameDay) {
30+
return `${startDate}, ${startTimeStr} - ${endTimeStr}`;
31+
}
32+
const endDate = endTime.toLocaleDateString('en-US', dateOptions);
33+
return `${startDate}, ${startTimeStr} - ${endDate}, ${endTimeStr}`;
34+
}
35+
36+
export default async function Image({
37+
params,
38+
}: {
39+
params: { id: string };
40+
}) {
41+
const id = (await params).id;
42+
43+
const eventData = await db.query.events.findFirst({
44+
where: (events) => and(eq(events.id, id), eq(events.status, 'approved')),
45+
with: {
46+
club: {
47+
columns: { name: true, profileImage: true, updatedAt: true },
48+
},
49+
},
50+
});
51+
52+
const gradientBuffer = await fetch(
53+
new URL('../../../../public/images/landingGradient.png', import.meta.url),
54+
).then((res) => res.arrayBuffer());
55+
56+
const baiJamjureeBuffer = await loadGoogleFont('Bai Jamjuree', 700);
57+
const interBuffer = await loadGoogleFont('Inter', 600);
58+
59+
const background = (
60+
<img
61+
// @ts-expect-error ArrayBuffers are allowed as an img source
62+
src={gradientBuffer}
63+
alt="background gradient"
64+
style={{
65+
position: 'absolute',
66+
top: 0,
67+
left: 0,
68+
width: '100%',
69+
height: '100%',
70+
objectFit: 'cover',
71+
}}
72+
/>
73+
);
74+
75+
if (!eventData) {
76+
return new ImageResponse(
77+
<div
78+
style={{
79+
position: 'relative',
80+
width: '100%',
81+
height: '100%',
82+
display: 'flex',
83+
flexDirection: 'column',
84+
alignItems: 'center',
85+
justifyContent: 'center',
86+
color: 'white',
87+
}}
88+
>
89+
{background}
90+
<h1>Event Not Found</h1>
91+
</div>,
92+
{ ...size },
93+
);
94+
}
95+
96+
const hasImage = !!eventData.image;
97+
const dateStr = formatEventDate(eventData.startTime, eventData.endTime);
98+
99+
return new ImageResponse(
100+
<div
101+
style={{
102+
position: 'relative',
103+
width: '100%',
104+
height: '100%',
105+
display: 'flex',
106+
flexDirection: 'row',
107+
alignItems: 'center',
108+
justifyContent: hasImage ? 'space-between' : 'center',
109+
color: 'white',
110+
}}
111+
>
112+
{background}
113+
114+
{/* Left Side (renders if there's an event image) */}
115+
{hasImage && (
116+
<div
117+
style={{ display: 'flex', width: '45%', justifyContent: 'center' }}
118+
>
119+
<div
120+
style={{
121+
display: 'flex',
122+
position: 'relative',
123+
alignItems: 'center',
124+
justifyContent: 'center',
125+
width: 350,
126+
height: 350,
127+
borderRadius: '20px',
128+
boxShadow: '0 0 16px rgba(0,0,0,0.4)',
129+
overflow: 'hidden',
130+
}}
131+
>
132+
<img
133+
src={addVersionToImage(
134+
eventData.image!,
135+
eventData.updatedAt.getTime(),
136+
)}
137+
alt={eventData.name + ' event image'}
138+
style={{
139+
width: '100%',
140+
objectFit: 'contain',
141+
}}
142+
/>
143+
</div>
144+
</div>
145+
)}
146+
147+
{/* Right Side (Content) */}
148+
<div
149+
style={{
150+
display: 'flex',
151+
flexDirection: 'column',
152+
width: '95%',
153+
alignItems: hasImage ? 'flex-start' : 'center',
154+
paddingRight: hasImage ? '40px' : '0px',
155+
paddingLeft: hasImage ? '0px' : '25px',
156+
}}
157+
>
158+
<h1
159+
style={{
160+
fontFamily: 'Bai Jamjuree',
161+
fontSize: '56px',
162+
fontWeight: 'bold',
163+
margin: '0 0 16px 0',
164+
lineHeight: 1.1,
165+
textShadow: '0 0 16px rgba(0,0,0,0.4)',
166+
maxWidth: hasImage ? '55%' : '90%',
167+
textAlign: hasImage ? 'left' : 'center',
168+
wordBreak: 'break-word',
169+
overflowWrap: 'anywhere',
170+
overflow: 'hidden',
171+
maxHeight: '200px',
172+
}}
173+
>
174+
{eventData.name}
175+
</h1>
176+
177+
{/* Date & Time */}
178+
<div
179+
style={{
180+
display: 'flex',
181+
fontFamily: 'Inter',
182+
fontSize: '24px',
183+
margin: '0 0 12px 0',
184+
textShadow: '0 0 4px rgba(0,0,0,0.4)',
185+
maxWidth: hasImage ? '55%' : '90%',
186+
textAlign: hasImage ? 'left' : 'center',
187+
}}
188+
>
189+
{dateStr}
190+
</div>
191+
192+
{/* Location */}
193+
{eventData.location && (
194+
<div
195+
style={{
196+
display: 'flex',
197+
fontFamily: 'Inter',
198+
fontSize: '22px',
199+
margin: '0 0 16px 0',
200+
textShadow: '0 0 4px rgba(0,0,0,0.4)',
201+
opacity: 0.9,
202+
maxWidth: hasImage ? '55%' : '90%',
203+
textAlign: hasImage ? 'left' : 'center',
204+
}}
205+
>
206+
{eventData.location}
207+
</div>
208+
)}
209+
210+
{/* Details Container */}
211+
<div
212+
style={{
213+
display: 'flex',
214+
flexDirection: 'row',
215+
justifyContent: hasImage ? 'flex-start' : 'center',
216+
alignItems: 'center',
217+
width: '100%',
218+
fontFamily: 'Inter',
219+
fontSize: '22px',
220+
margin: '0 0 16px 0',
221+
gap: '12px',
222+
}}
223+
>
224+
{/* Club name */}
225+
{eventData.club.profileImage && (
226+
<div
227+
style={{
228+
display: 'flex',
229+
position: 'relative',
230+
alignItems: 'center',
231+
justifyContent: 'center',
232+
width: 32,
233+
height: 32,
234+
borderRadius: '50%',
235+
overflow: 'hidden',
236+
}}
237+
>
238+
<img
239+
src={addVersionToImage(
240+
eventData.club.profileImage,
241+
eventData.club.updatedAt?.getTime(),
242+
)}
243+
alt={eventData.club.name + ' logo'}
244+
style={{
245+
width: '100%',
246+
height: '100%',
247+
objectFit: 'cover',
248+
}}
249+
/>
250+
</div>
251+
)}
252+
<div
253+
style={{
254+
display: 'flex',
255+
textShadow: '0 0 4px rgba(0,0,0,0.4)',
256+
}}
257+
>
258+
{eventData.club.name}
259+
</div>
260+
261+
{/* Divider */}
262+
<div
263+
style={{
264+
width: '2px',
265+
height: '25px',
266+
backgroundColor: '#d4d4d4',
267+
margin: '0 10px 0 9px',
268+
alignSelf: 'center',
269+
}}
270+
/>
271+
272+
{/* Nebula Logo */}
273+
<div
274+
style={{
275+
display: 'flex',
276+
alignItems: 'center',
277+
justifyContent: 'center',
278+
width: 36,
279+
height: 36,
280+
}}
281+
>
282+
<UTDClubsLogoStandalone
283+
fill="white"
284+
style={{
285+
width: '100%',
286+
height: '100%',
287+
objectFit: 'contain',
288+
}}
289+
/>
290+
</div>
291+
<div
292+
style={{
293+
display: 'flex',
294+
textShadow: '0 0 4px rgba(0,0,0,0.4)',
295+
}}
296+
>
297+
UTD CLUBS
298+
</div>
299+
</div>
300+
</div>
301+
</div>,
302+
{
303+
...size,
304+
fonts: [
305+
{
306+
name: 'Bai Jamjuree',
307+
data: baiJamjureeBuffer,
308+
style: 'normal',
309+
weight: 700,
310+
},
311+
{
312+
name: 'Inter',
313+
data: interBuffer,
314+
style: 'normal',
315+
weight: 600,
316+
},
317+
],
318+
},
319+
);
320+
}
321+
322+
async function loadGoogleFont(font: string, weight: number) {
323+
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&display=swap`;
324+
const css = await fetch(url).then((res) => res.text());
325+
const resource = css.match(
326+
/src: url\((.+)\) format\('(opentype|truetype)'\)/,
327+
);
328+
329+
if (!resource) {
330+
throw new Error('Failed to load font');
331+
}
332+
333+
return fetch(resource[1]!).then((res) => res.arrayBuffer());
334+
}

src/app/events/[id]/page.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ export async function generateMetadata(props: {
3838
description: 'Event not found',
3939
};
4040

41+
const dateOptions: Intl.DateTimeFormatOptions = {
42+
weekday: 'short',
43+
month: 'short',
44+
day: 'numeric',
45+
hour: 'numeric',
46+
minute: '2-digit',
47+
hour12: true,
48+
};
49+
const startStr = event.startTime.toLocaleString('en-US', dateOptions);
50+
const endStr = event.endTime.toLocaleString('en-US', dateOptions);
51+
4152
let cleanDescription = `${event.name} from ${event.club.name} on UTD Clubs`;
4253
const textDescription = event.description.replace(/^#+.*$/gm, '');
4354

@@ -53,15 +64,22 @@ export async function generateMetadata(props: {
5364
}
5465
}
5566

67+
const timeDescription = `${startStr} - ${endStr}${event.location ? ` | ${event.location}` : ''}`;
68+
5669
return {
57-
title: `${event.name}`,
58-
description: cleanDescription,
70+
title: event.name,
71+
description: `${timeDescription}. ${cleanDescription}`,
5972
alternates: {
6073
canonical: `https://clubs.utdnebula.com/events/${event.id}`,
6174
},
6275
openGraph: {
76+
title: event.name,
6377
url: `https://clubs.utdnebula.com/events/${event.id}`,
64-
description: cleanDescription,
78+
description: `${timeDescription}. ${cleanDescription}`,
79+
type: 'website',
80+
},
81+
twitter: {
82+
card: 'summary_large_image',
6583
},
6684
};
6785
}

0 commit comments

Comments
 (0)