diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index 32c7fdeb..13b981db 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -78,6 +78,17 @@ import { PreviewMenu as PreviewMenu_50b93d8a0176cb6161ede3dcf4f0f442 } from '@/p import { DocKebabExtras as DocKebabExtras_6380547e5dd048a503ca7dedfff6bec0 } from '@/payload/admin/components/DocKebabExtras.tsx' import { CmsPublishButton as CmsPublishButton_c651128bf4837d4639a448413057b93f } from '@/payload/admin/components/CmsPublishButton.tsx' import { CmsListView as CmsListView_339f71bcd64a39b547313d733bae8857 } from '@/payload/admin/components/views/list/CmsListView.tsx' +import { RowField as RowField_1ef7316c3d3f9450222b0eb086a8e243 } from '@/payload/admin/components/fields/RowField.tsx' +import { CatalogImagePicker as CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049 } from '@/payload/admin/components/CatalogImagePicker.tsx' +import { VideoSourceSummary as VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27 } from '@/payload/admin/components/VideoSourceSummary.tsx' +import { VideoTitleCell as VideoTitleCell_97af48f797dcdd14d8fbf98424a4fbcf } from '@/payload/admin/components/VideoTitleCell.tsx' +import { RadioField as RadioField_31aae9b520654e49d40e444571bafe41 } from '@/payload/admin/components/fields/RadioField.tsx' +import { VideoTopicSuggestions as VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591 } from '@/payload/admin/components/VideoTopicSuggestions.tsx' +import { UseDescriptionSummary as UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232 } from '@/payload/admin/components/UseDescriptionSummary.tsx' +import { CollapsibleField as CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8 } from '@/payload/admin/components/fields/CollapsibleField.tsx' +import { VideoThumbCell as VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a } from '@/payload/admin/components/VideoThumbCell.tsx' +import { VideoLengthCell as VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774 } from '@/payload/admin/components/VideoLengthCell.tsx' +import { VideoStateCell as VideoStateCell_70d255ecab4ca7e768a3cff708566031 } from '@/payload/admin/components/VideoStateCell.tsx' import { GateFormLabel as GateFormLabel_c191dd670871074ae1a1cea086f78af8 } from '@/payload/admin/components/GateFormLabel.tsx' import { DateField as DateField_a74145b29e7a0acd050f8f68af83aab6 } from '@/payload/admin/components/fields/DateField.tsx' import { JoinField as JoinField_bd3b4cc9a58da6642d24527d78f5c3b6 } from '@/payload/admin/components/fields/JoinField.tsx' @@ -118,7 +129,6 @@ import { HealthBadge as HealthBadge_89f48d3894398c6d1021775cfdeaf211 } from '@/p import { TestButton as TestButton_0a975e83e531dadfb4f8fa6589a5704a } from '@/payload/admin/components/integrations/TestButton.tsx' import { AuditTrail as AuditTrail_eca9fb91faa70a942f7e3507305e451c } from '@/payload/admin/components/integrations/AuditTrail.tsx' import { GlobalSchemaView as GlobalSchemaView_962751a33d0bde2910843d19f617ee83 } from '../../../payload/admin/components/SchemaManager/GlobalSchemaView.tsx' -import { CollapsibleField as CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8 } from '@/payload/admin/components/fields/CollapsibleField.tsx' import { Icon as Icon_c822e198d4078a1912cce52e024df304 } from '../../../payload/admin/Icon.tsx' import { Logo as Logo_dcbc6ddf8fe8d700973cf459e9b44aa4 } from '../../../payload/admin/Logo.tsx' import { SkipLink as SkipLink_27a678dd284d4042d03686d8b9a9b8e9 } from '../../../payload/admin/components/SkipLink.tsx' @@ -147,16 +157,7 @@ import { LeadAttributionView as LeadAttributionView_09cce16329a31c9c0551fcad8db2 import { CacheView as CacheView_53dd99c40bf5dfa0411c06d06a42a42b } from '../../../payload/admin/components/cache/CacheView.tsx' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { PointField as PointField_dc800e7ca3d2eb7ea39fd4bc6138c687 } from '@/payload/admin/components/fields/PointField.tsx' -import { RadioField as RadioField_31aae9b520654e49d40e444571bafe41 } from '@/payload/admin/components/fields/RadioField.tsx' import { TabsField as TabsField_c014faddfba6da88b9efedda256f4990 } from '@/payload/admin/components/fields/TabsField.tsx' -import { RowField as RowField_1ef7316c3d3f9450222b0eb086a8e243 } from '@/payload/admin/components/fields/RowField.tsx' -import { VideoSourceSummary as VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27 } from '@/payload/admin/components/VideoSourceSummary.tsx' -import { VideoThumbCell as VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a } from '@/payload/admin/components/VideoThumbCell.tsx' -import { VideoLengthCell as VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774 } from '@/payload/admin/components/VideoLengthCell.tsx' -import { VideoStateCell as VideoStateCell_70d255ecab4ca7e768a3cff708566031 } from '@/payload/admin/components/VideoStateCell.tsx' -import { VideoTopicSuggestions as VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591 } from '@/payload/admin/components/VideoTopicSuggestions.tsx' -import { UseDescriptionSummary as UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232 } from '@/payload/admin/components/UseDescriptionSummary.tsx' -import { CatalogImagePicker as CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049 } from '@/payload/admin/components/CatalogImagePicker.tsx' /** @type import('payload').ImportMap */ export const importMap = { @@ -240,6 +241,17 @@ export const importMap = { "@/payload/admin/components/DocKebabExtras.tsx#DocKebabExtras": DocKebabExtras_6380547e5dd048a503ca7dedfff6bec0, "@/payload/admin/components/CmsPublishButton.tsx#CmsPublishButton": CmsPublishButton_c651128bf4837d4639a448413057b93f, "@/payload/admin/components/views/list/CmsListView.tsx#CmsListView": CmsListView_339f71bcd64a39b547313d733bae8857, + "@/payload/admin/components/fields/RowField.tsx#RowField": RowField_1ef7316c3d3f9450222b0eb086a8e243, + "@/payload/admin/components/CatalogImagePicker.tsx#CatalogImagePicker": CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049, + "@/payload/admin/components/VideoSourceSummary.tsx#VideoSourceSummary": VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27, + "@/payload/admin/components/VideoTitleCell.tsx#VideoTitleCell": VideoTitleCell_97af48f797dcdd14d8fbf98424a4fbcf, + "@/payload/admin/components/fields/RadioField.tsx#RadioField": RadioField_31aae9b520654e49d40e444571bafe41, + "@/payload/admin/components/VideoTopicSuggestions.tsx#VideoTopicSuggestions": VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591, + "@/payload/admin/components/UseDescriptionSummary.tsx#UseDescriptionSummary": UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232, + "@/payload/admin/components/fields/CollapsibleField.tsx#CollapsibleField": CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8, + "@/payload/admin/components/VideoThumbCell.tsx#VideoThumbCell": VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a, + "@/payload/admin/components/VideoLengthCell.tsx#VideoLengthCell": VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774, + "@/payload/admin/components/VideoStateCell.tsx#VideoStateCell": VideoStateCell_70d255ecab4ca7e768a3cff708566031, "@/payload/admin/components/GateFormLabel.tsx#GateFormLabel": GateFormLabel_c191dd670871074ae1a1cea086f78af8, "@/payload/admin/components/fields/DateField.tsx#DateField": DateField_a74145b29e7a0acd050f8f68af83aab6, "@/payload/admin/components/fields/JoinField.tsx#JoinField": JoinField_bd3b4cc9a58da6642d24527d78f5c3b6, @@ -280,7 +292,6 @@ export const importMap = { "@/payload/admin/components/integrations/TestButton.tsx#TestButton": TestButton_0a975e83e531dadfb4f8fa6589a5704a, "@/payload/admin/components/integrations/AuditTrail.tsx#AuditTrail": AuditTrail_eca9fb91faa70a942f7e3507305e451c, "./payload/admin/components/SchemaManager/GlobalSchemaView.tsx#GlobalSchemaView": GlobalSchemaView_962751a33d0bde2910843d19f617ee83, - "@/payload/admin/components/fields/CollapsibleField.tsx#CollapsibleField": CollapsibleField_e8aff66100db6bf10de9866e9f9ca5a8, "./payload/admin/Icon.tsx#Icon": Icon_c822e198d4078a1912cce52e024df304, "./payload/admin/Logo.tsx#Logo": Logo_dcbc6ddf8fe8d700973cf459e9b44aa4, "./payload/admin/components/SkipLink.tsx#SkipLink": SkipLink_27a678dd284d4042d03686d8b9a9b8e9, @@ -309,14 +320,5 @@ export const importMap = { "./payload/admin/components/cache/CacheView.tsx#CacheView": CacheView_53dd99c40bf5dfa0411c06d06a42a42b, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, "@/payload/admin/components/fields/PointField.tsx#PointField": PointField_dc800e7ca3d2eb7ea39fd4bc6138c687, - "@/payload/admin/components/fields/RadioField.tsx#RadioField": RadioField_31aae9b520654e49d40e444571bafe41, - "@/payload/admin/components/fields/TabsField.tsx#TabsField": TabsField_c014faddfba6da88b9efedda256f4990, - "@/payload/admin/components/fields/RowField.tsx#RowField": RowField_1ef7316c3d3f9450222b0eb086a8e243, - "@/payload/admin/components/VideoSourceSummary.tsx#VideoSourceSummary": VideoSourceSummary_c96fbcdf17e1ea6b0ec94f543434cd27, - "@/payload/admin/components/VideoThumbCell.tsx#VideoThumbCell": VideoThumbCell_a6fc69bad4fd684c1000e3675bb5a77a, - "@/payload/admin/components/VideoLengthCell.tsx#VideoLengthCell": VideoLengthCell_4ae28695c16ecdf12c15a176eb6e0774, - "@/payload/admin/components/VideoStateCell.tsx#VideoStateCell": VideoStateCell_70d255ecab4ca7e768a3cff708566031, - "@/payload/admin/components/VideoTopicSuggestions.tsx#VideoTopicSuggestions": VideoTopicSuggestions_354abe4af848e2bbd6afc4620ae30591, - "@/payload/admin/components/UseDescriptionSummary.tsx#UseDescriptionSummary": UseDescriptionSummary_19de247a3719b8cb4c2371ecb84ed232, - "@/payload/admin/components/CatalogImagePicker.tsx#CatalogImagePicker": CatalogImagePicker_2327e75ccd37996d4075a1fbf8a8a049, + "@/payload/admin/components/fields/TabsField.tsx#TabsField": TabsField_c014faddfba6da88b9efedda256f4990 } diff --git a/apps/cms/src/payload/admin/components/VideoThumbCell.tsx b/apps/cms/src/payload/admin/components/VideoThumbCell.tsx index bf89b1cd..29c1044b 100644 --- a/apps/cms/src/payload/admin/components/VideoThumbCell.tsx +++ b/apps/cms/src/payload/admin/components/VideoThumbCell.tsx @@ -2,13 +2,13 @@ import type { ReactElement } from 'react'; -type Row = { externalId?: string | null; source?: { thumbnailUrl?: string | null } | null }; +type Row = { id?: string | number; externalId?: string | null; source?: { thumbnailUrl?: string | null } | null }; export const VideoThumbCell = ({ rowData }: { rowData?: Row }): ReactElement => { const src = rowData?.source?.thumbnailUrl || (rowData?.externalId ? `https://i.ytimg.com/vi/${rowData.externalId}/mqdefault.jpg` : null); - return src ? ( + const frame = src ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( ); + // A custom cell in the first column replaces Payload's own row link, so the thumbnail links itself. + return rowData?.id == null ? ( + frame + ) : ( + + {frame} + + ); }; export default VideoThumbCell; diff --git a/apps/cms/src/payload/admin/components/VideoTitleCell.tsx b/apps/cms/src/payload/admin/components/VideoTitleCell.tsx new file mode 100644 index 00000000..c20ca46d --- /dev/null +++ b/apps/cms/src/payload/admin/components/VideoTitleCell.tsx @@ -0,0 +1,18 @@ +'use client'; + +import type { ReactElement } from 'react'; + +type Row = { id?: string | number }; + +/** + * Title cell for the Videos list. The linked column is the thumbnail, whose + * custom cell Payload does not wrap in a link, so the title carries one too and + * a click anywhere on the row's text opens the video. + */ +export const VideoTitleCell = ({ cellData, rowData }: { cellData?: string | null; rowData?: Row }): ReactElement => { + const title = cellData?.trim() || 'Untitled video'; + if (rowData?.id == null) return {title}; + return {title}; +}; + +export default VideoTitleCell; diff --git a/apps/cms/src/payload/collections/Videos.ts b/apps/cms/src/payload/collections/Videos.ts index 7230d42d..30b278b5 100644 --- a/apps/cms/src/payload/collections/Videos.ts +++ b/apps/cms/src/payload/collections/Videos.ts @@ -77,7 +77,7 @@ export const singleFeaturedHook: CollectionAfterChangeHook = async ({ doc, req, return doc; }; -/** The slug has no public meaning (videos play inline), so it is generated from the title and not shown. */ +/** The slug is the video page's URL (/videos/[slug]). It is filled once from the title and hidden, so editing the title never moves a published URL. */ const hiddenSlugField = (): Field => { const field = slugField({ source: 'title' }) as TextField; const { components: _components, position: _position, description: _description, ...admin } = field.admin ?? {}; @@ -87,8 +87,9 @@ const hiddenSlugField = (): Field => { /** * Editorial videos for /videos. Imported from YouTube by the hourly sync as * needs-review drafts (see `lib/videos`). The `source` group is owned by the - * sync, everything else by editors; neither overwrites the other. Videos play - * inline on the listing, so there is no per-video page, URL or SEO block here. + * sync, everything else by editors; neither overwrites the other. Each video has + * its own page at /videos/[slug], whose title and description come from the + * title and summary fields, so there is no separate SEO block here. */ export const Videos: CollectionConfig = { slug: 'videos', @@ -137,7 +138,11 @@ export const Videos: CollectionConfig = { 'Paste the link of the video. It is saved as the video ID and cannot be changed afterwards. The video shows on /videos only once it has a category, a topic and a summary and is published.', }, }, - { ...contentTitleField, label: 'Title on the website' }, + { + ...contentTitleField, + label: 'Title on the website', + admin: { components: { Cell: '@/payload/admin/components/VideoTitleCell.tsx#VideoTitleCell' } }, + }, hiddenSlugField(), { name: 'format', diff --git a/apps/cms/src/payload/lib/page-registry-seed.ts b/apps/cms/src/payload/lib/page-registry-seed.ts index 1d96cf03..75e166ea 100644 --- a/apps/cms/src/payload/lib/page-registry-seed.ts +++ b/apps/cms/src/payload/lib/page-registry-seed.ts @@ -133,8 +133,9 @@ export const PAGE_REGISTRY_SEED: readonly PageRegistrySeedRow[] = [ { path: '/advisories', title: 'Advisories', kind: 'cms-listing', order: 59, backingCollection: 'advisories' }, { path: '/advisories/[slug]', title: 'Advisory (template)', kind: 'cms-template', order: 60, backingCollection: 'advisories' }, // Videos: editorial collection fed by the YouTube sync (apps/cms videos + videoTopics). The page plays - // videos inline, so there is no per-video template route. + // videos inline, so there was no per-video route until each video got its own page for search. { path: '/videos', title: 'Videos', kind: 'cms-listing', order: 61, backingCollection: 'videos' }, + { path: '/videos/[slug]', title: 'Video (template)', kind: 'cms-template', order: 62, backingCollection: 'videos' }, ] as const; /** Fail-fast guard: paths unique + well-formed, templates carry a collection. */ diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index b5de308c..bb2f515a 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -35,6 +35,7 @@ - [Newsroom](https://www.cleanstart.com/news): Press releases, product launches, and company milestones. - [Events](https://www.cleanstart.com/events): DevSecOps and cybersecurity conference appearances and meetups. - [Webinars](https://www.cleanstart.com/webinars): On-demand and upcoming webinars on container security topics. +- [Videos](https://www.cleanstart.com/videos): Short explainers and product demos on container security, SBOMs, VEX, provenance and the software supply chain. - [Podcast](https://www.cleanstart.com/podcast): Leadership Exchange: industry leaders on container security and software supply chain risk. - [Knowledge Hub](https://www.cleanstart.com/knowledge-hub): Academy-style articles covering container fundamentals through advanced supply chain security. diff --git a/apps/web/src/app/sitemap.test.ts b/apps/web/src/app/sitemap.test.ts index 9d15e1c0..cfd7bf3d 100644 --- a/apps/web/src/app/sitemap.test.ts +++ b/apps/web/src/app/sitemap.test.ts @@ -46,12 +46,13 @@ describe("sitemap", () => { expect(urls).not.toContain("https://www.cleanstart.com/blogs/hidden"); }); - it("lists /videos only once the page is public, never per-video URLs, and never reads the videos collection", async () => { - // Videos play inline on /videos; there is no page per video to list. + it("lists /videos and every video page only once the page is public, without reading the videos collection", async () => { fetchMock.mockImplementation(async () => ok([])); const urls = (await (await load())()).map((e) => e.url); expect(urls.includes("https://www.cleanstart.com/videos")).toBe(VIDEOS_PAGE_PUBLIC); - expect(urls.filter((u) => u.includes("/videos/"))).toEqual([]); + const videoPages = urls.filter((u) => u.includes("/videos/")); + expect(videoPages.length > 0).toBe(VIDEOS_PAGE_PUBLIC); + expect(urls.filter((u) => u.endsWith("/videos/")).length).toBe(0); expect(fetchMock.mock.calls.map(([url]) => collectionOf(url as string))).not.toContain("videos"); }); diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index abdf57e5..060f6c25 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -2,7 +2,9 @@ import { fetchCMS } from '@/lib/cms-fetch'; import { effectiveModifiedAt } from '@/lib/published-date'; import { SITE_URL } from '@/lib/seo/canonical'; import { legalHref } from '@/lib/legal'; +import { getVideos } from '@/lib/videos'; import { VIDEOS_PAGE_PUBLIC } from '@/lib/videos-launch'; +import { videoHref, type Video } from '@/lib/videos-utils'; import { isIndexingAllowed } from '@/lib/seo/indexing'; import type { MetadataRoute } from 'next'; @@ -142,7 +144,6 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/software-bill-materials' }, { path: '/teams' }, { path: '/tricorder' }, - ...(VIDEOS_PAGE_PUBLIC ? [{ path: '/videos' }] : []), { path: '/vulnerability-remediation' }, { path: '/webinars' }, ]; @@ -167,6 +168,16 @@ function entry(path: string, lastModified?: string): MetadataRoute.Sitemap[numbe }; } +/** Empty until the page is public; a YouTube or CMS outage leaves the rest of the sitemap intact. */ +async function publicVideos(): Promise { + if (!VIDEOS_PAGE_PUBLIC) return []; + try { + return await getVideos(); + } catch { + return []; + } +} + export default async function sitemap(): Promise { if (!isIndexingAllowed()) return []; @@ -182,6 +193,7 @@ export default async function sitemap(): Promise { legal, knowledgeBase, advisories, + videos, ] = await Promise.all([ fetchDocs('blogs', BLOG_FILTER), fetchDocs('case-studies', BLOG_FILTER), @@ -194,10 +206,14 @@ export default async function sitemap(): Promise { fetchDocs('legalDocuments', BLOG_FILTER), fetchDocs('knowledgeBase', BLOG_FILTER), fetchDocs('advisories', BLOG_FILTER), + publicVideos(), ]); return [ ...STATIC_ROUTES.map((r) => entry(r.path)), + // `getVideos` is newest first, so the first video dates the listing. + ...(videos.length > 0 ? [entry('/videos', videos[0]?.publishedAt)] : []), + ...videos.map((v) => entry(videoHref(v), v.publishedAt)), ...knowledgeBase.map((k) => entry(`/knowledge-hub/${k.slug}`, effectiveModifiedAt(k))), ...blogs.map((b) => entry(`/blogs/${b.slug}`, effectiveModifiedAt(b))), ...advisories.map((a) => entry(`/advisories/${a.slug}`, effectiveModifiedAt(a))), diff --git a/apps/web/src/app/videos/[slug]/page.tsx b/apps/web/src/app/videos/[slug]/page.tsx new file mode 100644 index 00000000..820948da --- /dev/null +++ b/apps/web/src/app/videos/[slug]/page.tsx @@ -0,0 +1,205 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { JsonLdGraph } from "@/components/JsonLdGraph"; +import { Header } from "@/components/nav/Header"; +import { Footer } from "@/components/sections/Footer"; +import { ReadyToSecureCTA } from "@/components/sections/home/ReadyToSecureCTA"; +import { RelatedVideos } from "@/components/sections/videos/RelatedVideos"; +import { VideoMeta } from "@/components/sections/videos/VideoMeta"; +import { VideoPlayer } from "@/components/sections/videos/VideoPlayer"; +import { HERO_GRADIENT } from "@/components/sections/videos/VideosHero"; +import { toIso8601Duration } from "@/lib/clean-libraries-video"; +import { youtubeThumbnail, youtubeWatchUrl } from "@/lib/podcast-utils"; +import { buildPageMetadata, clampMetaDescription } from "@/lib/seo/canonical"; +import { buildPageGraph } from "@/lib/seo/compose-page"; +import { breadcrumbSchema, videoObjectSchema, webPageSchema } from "@/lib/seo/jsonld"; +import { getVideoBySlug, getVideos } from "@/lib/videos"; +import { VIDEOS_PAGE_PUBLIC } from "@/lib/videos-launch"; +import { descriptionParagraphs, upNext, videoHref, videoFiltersQuery, DEFAULT_VIDEO_FILTERS } from "@/lib/videos-utils"; + +interface VideoPageProps { + params: Promise<{ slug: string }>; +} + +// Slugs not returned here still render on first request, then cache (ISR), so a video +// published after the last deploy gets its page without one. +export const dynamicParams = true; +export const revalidate = 3600; + +const RELATED_COUNT = 6; + +export async function generateStaticParams(): Promise> { + try { + return (await getVideos()).map((v) => ({ slug: v.slug })); + } catch { + return []; + } +} + +export async function generateMetadata({ params }: VideoPageProps): Promise { + const { slug } = await params; + const video = await getVideoBySlug(slug); + if (!video) { + return buildPageMetadata({ + title: "Video", + description: "CleanStart video.", + path: `/videos/${slug}`, + noindex: true, + }); + } + const thumb = video.thumb === "hq" ? "hqdefault" : "maxresdefault"; + return buildPageMetadata({ + title: video.title, + description: clampMetaDescription(video.summary || `Watch ${video.title} from CleanStart.`), + path: videoHref(video), + eyebrow: "Video", + image: { + url: youtubeThumbnail(video.id, thumb), + width: thumb === "hqdefault" ? 480 : 1280, + height: thumb === "hqdefault" ? 360 : 720, + alt: video.title, + }, + noindex: !VIDEOS_PAGE_PUBLIC, + nofollow: !VIDEOS_PAGE_PUBLIC, + }); +} + +export default async function VideoPage({ params }: VideoPageProps): Promise { + const { slug } = await params; + const [video, videos] = await Promise.all([getVideoBySlug(slug), getVideos()]); + if (!video) notFound(); + + const related = upNext(videos, video, RELATED_COUNT); + const paragraphs = descriptionParagraphs(video); + const thumb = video.thumb === "hq" ? "hqdefault" : "maxresdefault"; + + return ( + <> + +
+
+
+
+
+ +
+ +

+ {video.title} +

+
+ +
+
+ +
+
+

+ About this video +

+ {video.summary ? ( +

+ {video.summary} +

+ ) : null} + {paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} + {video.topics.length > 0 ? ( +
    + {video.topics.map((topic) => ( +
  • + + {topic.label} + +
  • + ))} +
+ ) : null} +
+ +

+ + All videos + +

+
+
+