Skip to content

Commit 69a4058

Browse files
Slashgearclaude
andcommitted
feat: add dedicated replay pages per talk for video SEO
Create individual /evenement/[slug]/replay/[talkSlug] pages so each video has its own "watch page", fixing Google Search Console "video not on a watch page" errors. - Add talkSlug and youtubeUtils shared utilities - Add replay page with VideoObject JSON-LD, OG video meta, and Twitter player card - Replace inline iframes in EventDetail with simple text links to replay pages - Move replay section above description in event pages - Update LastReplays homepage links to point to dedicated replay pages - Move video sitemap metadata from event entries to replay entries only - Add subjectOf VideoObject array to Event structured data markup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 64227b5 commit 69a4058

12 files changed

Lines changed: 375 additions & 62 deletions

File tree

app/evenement/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default async function EventPage({ params }: { params: Promise<{ slug: st
2424

2525
return (
2626
<main>
27-
<EventDetail event={event} />
27+
<EventDetail event={event} slug={slug} />
2828
<EventMarkup event={event} />
2929
</main>
3030
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
.page {
2+
max-width: 900px;
3+
margin: 0 auto;
4+
padding-top: 40px;
5+
}
6+
7+
.videoWrapper {
8+
position: relative;
9+
width: 100%;
10+
aspect-ratio: 16 / 9;
11+
}
12+
13+
.videoWrapper iframe {
14+
position: absolute;
15+
inset: 0;
16+
width: 100%;
17+
height: 100%;
18+
border: none;
19+
border-radius: 8px;
20+
}
21+
22+
.info {
23+
margin-top: 24px;
24+
}
25+
26+
.title {
27+
font-size: 1.5rem;
28+
margin: 0;
29+
}
30+
31+
.speakers {
32+
margin-top: 8px;
33+
font-size: 1.1rem;
34+
color: var(--grey-1);
35+
}
36+
37+
.speakers a {
38+
color: var(--primary);
39+
text-decoration: underline;
40+
}
41+
42+
.backLink {
43+
display: inline-block;
44+
margin-top: 16px;
45+
color: var(--primary);
46+
text-decoration: none;
47+
}
48+
49+
.backLink:hover {
50+
text-decoration: underline;
51+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ImageResponse } from 'next/og';
2+
import { SimpleText } from '../../../../../modules/og/SimpleText';
3+
4+
export const runtime = 'edge';
5+
6+
export const alt = 'LyonJS logo';
7+
export const size = {
8+
width: 1200,
9+
height: 630,
10+
};
11+
export const contentType = 'image/png';
12+
13+
export default async function Image({ params }: { params: Promise<{ talkSlug: string }> }) {
14+
const { talkSlug } = await params;
15+
const title = talkSlug.replace(/-/g, ' ');
16+
return new ImageResponse(<SimpleText text={title} />, {
17+
...size,
18+
});
19+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import React from 'react';
2+
import { Metadata } from 'next';
3+
import Link from 'next/link';
4+
import { notFound } from 'next/navigation';
5+
import { fetchPastEvents } from '../../../../../modules/meetup/queries/past-events.api';
6+
import { fetchEvent } from '../../../../../modules/meetup/queries/event.api';
7+
import { overrideEvent } from '../../../../../modules/event/overrideEvent';
8+
import { slugEventTitle, parserEventIdFromSlug } from '../../../../../modules/event/eventSlug';
9+
import { slugTalkTitle, findTalkBySlug } from '../../../../../modules/event/talkSlug';
10+
import { youtubeWatchUrl, youtubeThumbnailUrl } from '../../../../../modules/event/youtubeUtils';
11+
import { JsonLD } from '../../../../../modules/seo/JsonLD';
12+
import { ORGANISATION_MARKUP } from '../../../../org-markup';
13+
import styles from './ReplayPage.module.css';
14+
15+
type Params = { slug: string; talkSlug: string };
16+
17+
export async function generateStaticParams() {
18+
const pastEvents = await fetchPastEvents();
19+
const params: Params[] = [];
20+
21+
for (const rawEvent of pastEvents) {
22+
const event = overrideEvent(rawEvent);
23+
if (!event.talks) continue;
24+
const eventSlug = slugEventTitle(event);
25+
for (const talk of event.talks) {
26+
if (!talk.videoLink) continue;
27+
params.push({ slug: eventSlug, talkSlug: slugTalkTitle(talk) });
28+
}
29+
}
30+
31+
return params;
32+
}
33+
34+
async function getEventAndTalk(slug: string, talkSlug: string) {
35+
const eventId = parserEventIdFromSlug(slug);
36+
if (!eventId) return null;
37+
38+
try {
39+
const event = overrideEvent(await fetchEvent(eventId));
40+
if (!event.talks) return null;
41+
const talk = findTalkBySlug(event.talks, talkSlug);
42+
if (!talk?.videoLink) return null;
43+
return { event, talk };
44+
} catch {
45+
return null;
46+
}
47+
}
48+
49+
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
50+
const { slug, talkSlug } = await params;
51+
const result = await getEventAndTalk(slug, talkSlug);
52+
if (!result) return {};
53+
54+
const { event, talk } = result;
55+
const speakers = talk.speakers?.map((s) => s.name).join(', ') ?? '';
56+
const title = `${talk.title} - ${speakers} | LyonJS`;
57+
const description = `Replay de "${talk.title}" par ${speakers}, présenté lors de ${event.title}`;
58+
const thumbnailUrl = youtubeThumbnailUrl(talk.videoLink!);
59+
60+
return {
61+
title,
62+
description,
63+
openGraph: {
64+
title,
65+
description,
66+
type: 'video.other',
67+
videos: [{ url: talk.videoLink! }],
68+
images: thumbnailUrl ? [{ url: thumbnailUrl }] : undefined,
69+
},
70+
twitter: {
71+
card: 'player',
72+
title,
73+
description,
74+
players: [
75+
{
76+
playerUrl: talk.videoLink!,
77+
streamUrl: talk.videoLink!,
78+
width: 1280,
79+
height: 720,
80+
},
81+
],
82+
},
83+
};
84+
}
85+
86+
export default async function ReplayPage({ params }: { params: Promise<Params> }) {
87+
const { slug, talkSlug } = await params;
88+
const result = await getEventAndTalk(slug, talkSlug);
89+
if (!result) notFound();
90+
91+
const { event, talk } = result;
92+
const speakers = talk.speakers?.map((s) => s.name).join(', ') ?? '';
93+
const watchUrl = youtubeWatchUrl(talk.videoLink!);
94+
const thumbnailUrl = youtubeThumbnailUrl(talk.videoLink!);
95+
96+
return (
97+
<main className={styles.page}>
98+
<div className={styles.videoWrapper}>
99+
<iframe
100+
src={talk.videoLink}
101+
title={talk.title}
102+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
103+
allowFullScreen
104+
/>
105+
</div>
106+
107+
<div className={styles.info}>
108+
<h1 className={styles.title}>{talk.title}</h1>
109+
110+
{talk.speakers && talk.speakers.length > 0 && (
111+
<p className={styles.speakers}>
112+
{talk.speakers.map((speaker, i) => (
113+
<span key={speaker.name}>
114+
{i > 0 && ', '}
115+
{speaker.socialLink ? (
116+
<a href={speaker.socialLink} target="_blank" rel="noopener noreferrer">
117+
{speaker.name}
118+
</a>
119+
) : (
120+
speaker.name
121+
)}
122+
</span>
123+
))}
124+
</p>
125+
)}
126+
127+
<Link href={`/evenement/${slug}`} className={styles.backLink}>
128+
&larr; Retour à {event.title}
129+
</Link>
130+
</div>
131+
132+
<JsonLD
133+
jsonObject={{
134+
'@context': 'https://schema.org',
135+
'@type': 'VideoObject',
136+
name: talk.title,
137+
description: `Replay de "${talk.title}" par ${speakers}, présenté lors de ${event.title}`,
138+
thumbnailUrl: thumbnailUrl ? [thumbnailUrl] : [],
139+
embedUrl: talk.videoLink,
140+
contentUrl: watchUrl,
141+
uploadDate: event.dateTime,
142+
publisher: ORGANISATION_MARKUP,
143+
}}
144+
/>
145+
</main>
146+
);
147+
}

app/sitemap.ts

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@ import { MetadataRoute } from 'next';
22
import { fetchYearsWithMeetups } from '../modules/meetup/queries/years-with-meetups';
33
import { fetchPastEvents } from '../modules/meetup/queries/past-events.api';
44
import { slugEventTitle } from '../modules/event/eventSlug';
5+
import { slugTalkTitle } from '../modules/event/talkSlug';
56
import { overrideEvent } from '../modules/event/overrideEvent';
7+
import { youtubeVideoId } from '../modules/event/youtubeUtils';
68

79
const BASE_URL = 'https://www.lyonjs.org';
810

9-
function youtubeVideoId(embedUrl: string): string | null {
10-
const match = embedUrl.match(/\/embed\/([^/?]+)/);
11-
return match?.[1] ?? null;
12-
}
13-
1411
function escapeXml(str: string): string {
1512
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1613
}
@@ -20,6 +17,54 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
2017
const pastEvents = await fetchPastEvents();
2118
const currentYear = new Date().getFullYear().toString();
2219

20+
const replayEntries: MetadataRoute.Sitemap = [];
21+
22+
const eventEntries: MetadataRoute.Sitemap = pastEvents.map((rawEvent) => {
23+
const event = overrideEvent(rawEvent);
24+
const eventSlug = slugEventTitle(event);
25+
const eventYear = new Date(event.dateTime).getFullYear().toString();
26+
27+
const images: string[] = [];
28+
if (event.featuredEventPhoto?.highResUrl) {
29+
images.push(event.featuredEventPhoto.highResUrl);
30+
}
31+
32+
if (event.talks) {
33+
for (const talk of event.talks) {
34+
if (!talk.videoLink) continue;
35+
const videoId = youtubeVideoId(talk.videoLink);
36+
if (!videoId) continue;
37+
38+
const speakers = talk.speakers?.map((s) => s.name).join(', ') ?? '';
39+
40+
replayEntries.push({
41+
url: `${BASE_URL}/evenement/${eventSlug}/replay/${slugTalkTitle(talk)}`,
42+
lastModified: new Date(event.dateTime),
43+
changeFrequency: 'yearly' as const,
44+
priority: 0.5,
45+
videos: [
46+
{
47+
title: escapeXml(talk.title),
48+
thumbnail_loc: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
49+
description: escapeXml(`Replay de "${talk.title}" par ${speakers}, présenté lors de ${event.title}`),
50+
},
51+
],
52+
});
53+
}
54+
}
55+
56+
const entry: MetadataRoute.Sitemap[number] = {
57+
url: `${BASE_URL}/evenement/${eventSlug}`,
58+
lastModified: new Date(event.dateTime),
59+
changeFrequency: 'yearly' as const,
60+
priority: eventYear === currentYear ? 0.6 : 0.3,
61+
};
62+
63+
if (images.length > 0) entry.images = images;
64+
65+
return entry;
66+
});
67+
2368
return [
2469
{
2570
url: BASE_URL,
@@ -62,42 +107,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
62107
priority: year === currentYear ? 0.8 : 0.4,
63108
};
64109
}),
65-
...pastEvents.map((rawEvent) => {
66-
const event = overrideEvent(rawEvent);
67-
const eventYear = new Date(event.dateTime).getFullYear().toString();
68-
69-
const images: string[] = [];
70-
if (event.featuredEventPhoto?.highResUrl) {
71-
images.push(event.featuredEventPhoto.highResUrl);
72-
}
73-
74-
const videos: MetadataRoute.Sitemap[number]['videos'] = [];
75-
if (event.talks) {
76-
for (const talk of event.talks) {
77-
if (!talk.videoLink) continue;
78-
const videoId = youtubeVideoId(talk.videoLink);
79-
if (!videoId) continue;
80-
videos.push({
81-
title: escapeXml(talk.title),
82-
thumbnail_loc: `https://img.youtube.com/vi/${videoId}/0.jpg`,
83-
description: escapeXml(
84-
`${talk.title} - ${talk.speakers?.map((s) => s.name).join(', ') ?? ''} @ ${event.title}`,
85-
),
86-
});
87-
}
88-
}
89-
90-
const entry: MetadataRoute.Sitemap[number] = {
91-
url: `${BASE_URL}/evenement/${slugEventTitle(event)}`,
92-
lastModified: new Date(event.dateTime),
93-
changeFrequency: 'yearly' as const,
94-
priority: eventYear === currentYear ? 0.6 : 0.3,
95-
};
96-
97-
if (images.length > 0) entry.images = images;
98-
if (videos.length > 0) entry.videos = videos;
99-
100-
return entry;
101-
}),
110+
...eventEntries,
111+
...replayEntries,
102112
];
103113
}

modules/event/components/EventDetail.module.css

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@
5353
.replays {
5454
display: flex;
5555
flex-direction: column;
56-
gap: 32px;
56+
gap: 8px;
5757
}
5858

59-
.replays iframe {
60-
display: block;
61-
aspect-ratio: 16/9;
59+
.replayLink {
60+
color: var(--main-color);
61+
text-decoration: none;
62+
}
63+
64+
.replayLink:hover {
65+
text-decoration: underline;
66+
}
67+
68+
.replaySpeakers {
69+
color: var(--font-color-default);
6270
}

0 commit comments

Comments
 (0)