From e900284a14288c2d430d24f8944af2d13b54e0ef Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Sat, 31 Oct 2026 12:05:20 +0530 Subject: [PATCH 1/7] feat(web): /videos library with inline playback, a topic rail and a dark launch The /videos page: explainers and product demos from the YouTube channel, filtered by category and topic, each video playing in place (no per-video page, so nothing competes with the blog or guide on the same topic). Reads the code catalog plus the YouTube feed by default, or the CMS videos collection when VIDEOS_SOURCE=cms. Ships dark: lib/videos-launch.ts (VIDEOS_PAGE_PUBLIC = false) keeps the page noindex and nofollow, out of the sitemap and out of the Resources menu. Flip it on final approval. Also restores the /videos purge entries and the "Purge this page" button on the Videos admin screen that the CMS-only change leaves out until this route exists. --- apps/cms/src/payload/collections/Videos.ts | 2 +- .../collections/cache-purge-field.test.ts | 2 + .../cms/src/payload/lib/page-registry-seed.ts | 3 + .../src/payload/lib/route-prefixes.test.ts | 1 + apps/cms/src/payload/lib/route-prefixes.ts | 4 + apps/cms/src/payload/lib/web-pages.test.ts | 3 +- apps/cms/src/payload/lib/web-pages.ts | 2 + apps/web/next.config.ts | 7 + apps/web/src/app/sitemap.test.ts | 10 + apps/web/src/app/sitemap.ts | 2 + apps/web/src/app/videos/page.metadata.test.ts | 17 + apps/web/src/app/videos/page.tsx | 91 ++++ apps/web/src/components/search/search-ui.tsx | 2 + .../components/sections/videos/PlayGlyph.tsx | 14 + .../components/sections/videos/VideoCard.tsx | 109 +++++ .../components/sections/videos/VideoMeta.tsx | 26 ++ .../sections/videos/VideoPlayer.tsx | 118 +++++ .../sections/videos/VideoSelect.tsx | 80 ++++ .../components/sections/videos/VideoThumb.tsx | 30 ++ .../sections/videos/VideosBrowser.tsx | 418 ++++++++++++++++++ .../components/sections/videos/VideosHero.tsx | 103 +++++ apps/web/src/lib/nav-config.ts | 2 + apps/web/src/lib/search.ts | 1 + apps/web/src/lib/use-watched-videos.ts | 73 +++ apps/web/src/lib/videos-catalog.ts | 211 +++++++++ apps/web/src/lib/videos-cms.test.ts | 155 +++++++ apps/web/src/lib/videos-cms.ts | 90 ++++ apps/web/src/lib/videos-launch.ts | 6 + apps/web/src/lib/videos-utils.test.ts | 220 +++++++++ apps/web/src/lib/videos-utils.ts | 294 ++++++++++++ apps/web/src/lib/videos.test.ts | 76 ++++ apps/web/src/lib/videos.ts | 25 ++ apps/web/src/lib/youtube-feed.test.ts | 27 +- apps/web/src/lib/youtube-feed.ts | 11 + docs/web/WEB-PAGES.md | 1 + 35 files changed, 2233 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/videos/page.metadata.test.ts create mode 100644 apps/web/src/app/videos/page.tsx create mode 100644 apps/web/src/components/sections/videos/PlayGlyph.tsx create mode 100644 apps/web/src/components/sections/videos/VideoCard.tsx create mode 100644 apps/web/src/components/sections/videos/VideoMeta.tsx create mode 100644 apps/web/src/components/sections/videos/VideoPlayer.tsx create mode 100644 apps/web/src/components/sections/videos/VideoSelect.tsx create mode 100644 apps/web/src/components/sections/videos/VideoThumb.tsx create mode 100644 apps/web/src/components/sections/videos/VideosBrowser.tsx create mode 100644 apps/web/src/components/sections/videos/VideosHero.tsx create mode 100644 apps/web/src/lib/use-watched-videos.ts create mode 100644 apps/web/src/lib/videos-catalog.ts create mode 100644 apps/web/src/lib/videos-cms.test.ts create mode 100644 apps/web/src/lib/videos-cms.ts create mode 100644 apps/web/src/lib/videos-launch.ts create mode 100644 apps/web/src/lib/videos-utils.test.ts create mode 100644 apps/web/src/lib/videos-utils.ts create mode 100644 apps/web/src/lib/videos.test.ts create mode 100644 apps/web/src/lib/videos.ts diff --git a/apps/cms/src/payload/collections/Videos.ts b/apps/cms/src/payload/collections/Videos.ts index ef115aa97..f2aadc01f 100644 --- a/apps/cms/src/payload/collections/Videos.ts +++ b/apps/cms/src/payload/collections/Videos.ts @@ -99,7 +99,7 @@ export const Videos: CollectionConfig = { 'Videos shown on /videos. New YouTube uploads arrive as drafts marked "Needs review": choose a category and topics, write the summary, then publish.', group: 'Content', components: { - edit: docStatusBarEditConfig({ showStats: false, showPublishedAt: true, showPurge: false }), + edit: docStatusBarEditConfig({ showStats: false, showPublishedAt: true, showPurge: true }), }, }, defaultSort: '-publishedAt', diff --git a/apps/cms/src/payload/collections/cache-purge-field.test.ts b/apps/cms/src/payload/collections/cache-purge-field.test.ts index b59d63e35..698368378 100644 --- a/apps/cms/src/payload/collections/cache-purge-field.test.ts +++ b/apps/cms/src/payload/collections/cache-purge-field.test.ts @@ -15,6 +15,7 @@ import { LegalDocuments } from './Legal'; import { News } from './News'; import { PodcastEpisodes } from './PodcastEpisodes'; import { Resources } from './Resources'; +import { Videos } from './Videos'; import { Webinars } from './Webinars'; const COLLECTIONS: CollectionConfig[] = [ @@ -23,6 +24,7 @@ const COLLECTIONS: CollectionConfig[] = [ News, Guides, Resources, + Videos, Events, Jobs, KnowledgeBase, diff --git a/apps/cms/src/payload/lib/page-registry-seed.ts b/apps/cms/src/payload/lib/page-registry-seed.ts index 50a57a8bb..1d96cf039 100644 --- a/apps/cms/src/payload/lib/page-registry-seed.ts +++ b/apps/cms/src/payload/lib/page-registry-seed.ts @@ -132,6 +132,9 @@ export const PAGE_REGISTRY_SEED: readonly PageRegistrySeedRow[] = [ // Security advisories, a Resources section of their own. Launched 2026-10-23. { 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. + { path: '/videos', title: 'Videos', kind: 'cms-listing', order: 61, backingCollection: 'videos' }, ] as const; /** Fail-fast guard: paths unique + well-formed, templates carry a collection. */ diff --git a/apps/cms/src/payload/lib/route-prefixes.test.ts b/apps/cms/src/payload/lib/route-prefixes.test.ts index 17d07f1ac..0578bef9c 100644 --- a/apps/cms/src/payload/lib/route-prefixes.test.ts +++ b/apps/cms/src/payload/lib/route-prefixes.test.ts @@ -42,6 +42,7 @@ describe('listingPathForCollection (index URLs for revalidation)', () => { expect(listingPathForCollection('events')).toBe('/events'); expect(listingPathForCollection('webinars')).toBe('/webinars'); expect(listingPathForCollection('jobs')).toBe('/careers'); + expect(listingPathForCollection('videos')).toBe('/videos'); }); it('falls back to ROUTE_PREFIX when the listing matches the detail prefix', () => { diff --git a/apps/cms/src/payload/lib/route-prefixes.ts b/apps/cms/src/payload/lib/route-prefixes.ts index 9bd5d6490..a889e2d52 100644 --- a/apps/cms/src/payload/lib/route-prefixes.ts +++ b/apps/cms/src/payload/lib/route-prefixes.ts @@ -50,6 +50,10 @@ const LISTING_PATH_OVERRIDE: Record = { // must purge. `collectionUrlFromDoc` returns null for it (no ROUTE_PREFIX // entry), so the hook purges the listing alone. resourceTypes: '/resource-center', + // Videos play inline on the listing (no page per video), so there is no ROUTE_PREFIX entry and + // no detail URL: a publish purges the listing alone. Topics are its filter, so they purge it too. + videos: '/videos', + videoTopics: '/videos', events: '/events', webinars: '/webinars', jobs: '/careers', diff --git a/apps/cms/src/payload/lib/web-pages.test.ts b/apps/cms/src/payload/lib/web-pages.test.ts index 6f5ad282a..dbe95e758 100644 --- a/apps/cms/src/payload/lib/web-pages.test.ts +++ b/apps/cms/src/payload/lib/web-pages.test.ts @@ -11,6 +11,7 @@ describe('isPurgeableCollection', () => { for (const c of [ 'blogs', 'advisories', + 'videos', 'news', 'guides', 'resources', @@ -26,7 +27,7 @@ describe('isPurgeableCollection', () => { ]) { expect(isPurgeableCollection(c)).toBe(true); } - expect(Object.keys(PURGEABLE_COLLECTIONS)).toHaveLength(14); + expect(Object.keys(PURGEABLE_COLLECTIONS)).toHaveLength(15); }); it('excludes collections with no live web route', () => { diff --git a/apps/cms/src/payload/lib/web-pages.ts b/apps/cms/src/payload/lib/web-pages.ts index 5f01daf3c..303743abb 100644 --- a/apps/cms/src/payload/lib/web-pages.ts +++ b/apps/cms/src/payload/lib/web-pages.ts @@ -16,6 +16,8 @@ export interface WebPage { export const PURGEABLE_COLLECTIONS: Record = { blogs: { detailPrefix: '/blogs', listingPath: '/blogs' }, advisories: { detailPrefix: '/advisories', listingPath: '/advisories' }, + // Videos play inline on the listing: no per-video page to purge. + videos: { listingPath: '/videos' }, news: { detailPrefix: '/news', listingPath: '/news' }, guides: { detailPrefix: '/guide', listingPath: '/guide' }, resources: { detailPrefix: '/resources', listingPath: '/resource-center' }, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0cc7e7dad..aa684734c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -281,6 +281,13 @@ const nextConfig: NextConfig = { hostname: "storage.googleapis.com", pathname: "/cdpimages/**", }, + { + // YouTube poster frames for /videos. Only i.ytimg.com/vi//*.jpg is + // ever requested: the URL is built from the video id, never from feed data. + protocol: "https", + hostname: "i.ytimg.com", + pathname: "/vi/**", + }, { // Brand-colored stack logos (devicons) served via jsDelivr CDN protocol: "https", diff --git a/apps/web/src/app/sitemap.test.ts b/apps/web/src/app/sitemap.test.ts index a253c7162..9d15e1c01 100644 --- a/apps/web/src/app/sitemap.test.ts +++ b/apps/web/src/app/sitemap.test.ts @@ -1,3 +1,4 @@ +import { VIDEOS_PAGE_PUBLIC } from "@/lib/videos-launch"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("next/headers", () => ({ @@ -45,6 +46,15 @@ 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. + 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([]); + expect(fetchMock.mock.calls.map(([url]) => collectionOf(url as string))).not.toContain("videos"); + }); + it("never reports the row-level updatedAt as lastmod", async () => { fetchMock.mockImplementation(async (url: string) => collectionOf(url) === "blogs" diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 96915775c..abdf57e50 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -2,6 +2,7 @@ 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 { VIDEOS_PAGE_PUBLIC } from '@/lib/videos-launch'; import { isIndexingAllowed } from '@/lib/seo/indexing'; import type { MetadataRoute } from 'next'; @@ -141,6 +142,7 @@ const STATIC_ROUTES: ReadonlyArray<{ path: string }> = [ { path: '/software-bill-materials' }, { path: '/teams' }, { path: '/tricorder' }, + ...(VIDEOS_PAGE_PUBLIC ? [{ path: '/videos' }] : []), { path: '/vulnerability-remediation' }, { path: '/webinars' }, ]; diff --git a/apps/web/src/app/videos/page.metadata.test.ts b/apps/web/src/app/videos/page.metadata.test.ts new file mode 100644 index 000000000..2e5b26823 --- /dev/null +++ b/apps/web/src/app/videos/page.metadata.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/headers", () => ({ draftMode: async () => ({ isEnabled: false }) })); + +import { VIDEOS_PAGE_PUBLIC } from "@/lib/videos-launch"; +import { generateMetadata } from "./page"; + +describe("/videos launch gate", () => { + it("is not public yet", () => { + expect(VIDEOS_PAGE_PUBLIC).toBe(false); + }); + + it("is noindex and nofollow until it is made public", () => { + const robots = generateMetadata().robots; + expect(robots).toMatchObject({ index: false, follow: false }); + }); +}); diff --git a/apps/web/src/app/videos/page.tsx b/apps/web/src/app/videos/page.tsx new file mode 100644 index 000000000..631980ad0 --- /dev/null +++ b/apps/web/src/app/videos/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { Header } from "@/components/nav/Header"; +import { Footer } from "@/components/sections/Footer"; +import { ReadyToSecureCTA } from "@/components/sections/home/ReadyToSecureCTA"; +import { + VideosBrowser, + VideosBrowserFallback, + type TopicOption, +} from "@/components/sections/videos/VideosBrowser"; +import { + VIDEOS_DESCRIPTION, + VIDEOS_TITLE, + VideosHero, +} from "@/components/sections/videos/VideosHero"; +import { buildPageMetadata } from "@/lib/seo/canonical"; +import { VIDEOS_PAGE_PUBLIC } from "@/lib/videos-launch"; +import { toIso8601Duration } from "@/lib/clean-libraries-video"; +import { youtubeThumbnail, youtubeWatchUrl } from "@/lib/podcast-utils"; +import { JsonLd, breadcrumbSchema, videoObjectSchema } from "@/lib/seo/jsonld"; +import { getVideos, videoTopicOrder } from "@/lib/videos"; +import { topicCounts, type Video } from "@/lib/videos-utils"; + +// Static + ISR. The CMS read (or the YouTube feed, in catalog mode) behind +// `getVideos` is cached, and a CMS publish purges this path, so a change shows +// up without a deploy. +export const revalidate = 3600; + +export function generateMetadata(): Metadata { + return buildPageMetadata({ + title: VIDEOS_TITLE, + description: VIDEOS_DESCRIPTION, + path: "/videos", + eyebrow: "Videos", + // Until final approval: noindex AND nofollow (see lib/videos-launch.ts). + noindex: !VIDEOS_PAGE_PUBLIC, + nofollow: !VIDEOS_PAGE_PUBLIC, + }); +} + +// Videos play inline on this page, so the list itself is what search engines +// index: one VideoObject per embed, in a single ItemList. +const videoListSchema = (videos: readonly Video[]): Record => ({ + "@context": "https://schema.org", + "@type": "ItemList", + name: VIDEOS_TITLE, + itemListElement: videos.map((v, i) => { + const { "@context": _context, ...video } = videoObjectSchema({ + name: v.title, + description: v.summary, + contentUrl: youtubeWatchUrl(v.id), + embedUrl: `https://www.youtube.com/embed/${v.id}`, + uploadDate: v.publishedAt, + thumbnailUrl: youtubeThumbnail(v.id, v.thumb === "hq" ? "hqdefault" : "maxresdefault"), + ...(v.durationSeconds !== undefined ? { duration: toIso8601Duration(v.durationSeconds) } : {}), + }); + return { "@type": "ListItem", position: i + 1, item: video }; + }), +}); + +/** + * Filters live in the URL and are applied in memory by `VideosBrowser`, so the + * route stays static. The Suspense fallback is the unfiltered first view, which + * is what the static HTML carries; `CrawlableLinkIndex` adds the cards beyond + * the first page as plain links. Same shape as /webinars and /blogs. + */ +export default async function VideosPage(): Promise { + const videos = await getVideos(); + // An editor's "featured" pick leads; otherwise the newest video does. + const featured = videos.find((v) => v.featured) ?? videos[0] ?? null; + const heroId = featured?.id ?? null; + const topics: TopicOption[] = topicCounts(videos, videoTopicOrder()); + + return ( + <> + + {videos.length > 0 && } +
+
+ + }> + + +
+