The /videos page is the public-facing Matters AI video gallery. It pulls every video from
the WordPress video CPT, renders a hero + featured slot + filterable grid + deep-link modal
player, and emits VideoObject + ItemList JSON-LD for SEO.
This module follows the CPT-backed module pattern — read that page first for the shared blueprint. This page documents the video-specific details.
ProductVideo shape#Every video the gallery renders conforms to this shape — both the static fallback registry and the live CPT data are mapped to it before consumption:
// lib/data/product-videos.ts
export type ProductVideoCategory =
| "platform"
| "integrations"
| "use-cases"
| "events"
| "company";
export interface ProductVideo {
slug: string; // Deep-link key: /videos?v=<slug>
title: string;
description: string; // Card description (1–2 sentences)
longDescription?: string; // Featured-slot description
playbackId: string; // Mux playback ID — required
assetId: string; // Mux asset ID — used for analytics
youtubeId?: string; // Optional — shows "Watch on YouTube" link in modal
duration?: string; // e.g. "2:14"
category: ProductVideoCategory;
tags: string[];
featured?: boolean; // Hero slot (only one wins)
relatedLinks?: { title; href; kind?: "Blog" | "Datasheet" | "Webinar" | "Docs" | "Page" }[];
}
The CPT's content.raw stores this JSON minus slug and title (those map to WP's native
post.slug and post.title).
Workspace edit form ──┐
│ PUT /api/workspace/videos/<id>
▼
WP video CPT (private)
│
│ GET /wp-json/wp/v2/video?status=publish
▼
lib/data/videos-server.ts → getProductVideos()
│
(if CPT empty / errors → falls back to PRODUCT_VIDEOS static array)
▼
app/videos/page.tsx (server)
│ passes videos + featured as props
▼
app/videos/client-page.tsx (client)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
FeaturedVideo CategoryFilter VideoCard grid
+ ?v=<slug> deep-link
→ VideoModal
The video CPT is registered in WP admin via the Simple CPT plugin. Settings:
| Setting | Value |
|---|---|
| CPT Name | video |
| REST endpoint | /wp-json/wp/v2/video |
| Public | No |
| Show UI | No |
| Show in REST | Yes |
| Supports | Title, Editor, Excerpt, Revisions, Featured Image, Author, Page Attributes |
| File | Purpose |
|---|---|
| apps/workspace/app/(workspace)/videos/client-page.tsx | List page — table + drag-reorder via framer-motion Reorder.Group |
| apps/workspace/app/(workspace)/videos/[slug]/client-page.tsx | Edit form — title, slug, status + Mux IDs + YouTube ID + duration + category + tags + featured toggle + related links |
| apps/workspace/app/api/workspace/videos/route.ts | GET list, POST create |
| apps/workspace/app/api/workspace/videos/[id]/route.ts | GET, PUT, DELETE |
| apps/workspace/app/api/workspace/videos/reorder/route.ts | Batch [ORDER:NNN] excerpt updates |
| File | Purpose |
|---|---|
| app/videos/page.tsx | Server entry. Fetches videos, picks featured, emits ItemList + VideoObject JSON-LD, renders client. |
| app/videos/client-page.tsx | Client shell — hero, filters, grid, deep-link modal. |
| app/videos/components/VideoCard.tsx | Grid card with Mux poster + hover-preview MP4. |
| app/videos/components/FeaturedVideo.tsx | Hero-slot featured card. |
| app/videos/components/VideoModal.tsx | Modal player with related links + "next video" rail. |
| app/videos/components/CategoryFilter.tsx | Category pills + search input. |
| app/api/videos/list/route.ts | Public JSON API. Returns { videos, source }. |
| lib/data/videos-server.ts | Server-only helper — getProductVideos() + getFeaturedVideo(). CPT-or-fallback. |
| lib/data/product-videos.ts | ProductVideo type + static fallback array + category labels. |
getProductVideos() returns one of three sources:
The source is typed "wordpress" | "fallback" | "empty" (videos-server.ts:54),
but in practice getProductVideos() only ever returns two of them:
| Source | When | Effect |
|---|---|---|
"wordpress" | CPT returns ≥1 valid video | Live data |
"fallback" | CPT errors, the fetch is not ok, returns 0 valid videos, or an unexpected throw is caught | Static PRODUCT_VIDEOS array (the throw path also logs) |
"empty" | Reserved in the union type; not currently returned by getProductVideos() | — |
Every failure path resolves to "fallback", so /videos never shows an empty gallery during
migration or downtime. Once you publish CPT entries, those override the static fallback
automatically.
The gallery is a single page (/videos); individual videos open in a modal via a query
param:
/videos → gallery only
/videos?v=funding-journey → opens that video's modal on load
Implemented in client-page.tsx:
useEffect(() => {
const slug = searchParams.get(QUERY_KEY);
if (!slug) {
setActiveVideo(null);
return;
}
const match = videos.find((v) => v.slug === slug);
if (match) setActiveVideo(match);
}, [searchParams, videos]);
Sharing a ?v=... URL gets the recipient straight into the right modal — used by the
navbar "Resources" carousel and as the workspace's "View Live" action.
The server page injects:
ItemList — the full gallery, lets Google show "video carousel" rich results.VideoObject per video — name, description, Mux thumbnail, m3u8 content URL, embed
URL pointing to /videos?v=<slug>.If you change the uploadDate field (currently hardcoded "2025-01-01"), make it dynamic
from the WP post's modified field.
The Google sitemap entry lives at app/(sitemaps)/sitemap-videos.xml/route.ts — note that one scans blog posts for embedded YouTube/Mux/Vimeo videos, not the video CPT. The CPT
sitemap entry doesn't exist yet — add it if you want individual gallery deep-links indexed.
The Resources carousel in components/nav/Navbar.tsx
already fetches live data. It seeds navVideos from the static PRODUCT_VIDEOS array (via
SOCIAL_VIDEOS_MATTERS) so the carousel paints immediately, then a useEffect (~line 247)
fetches /api/videos/list with { cache: "no-store" } and swaps in the live CPT data once it
resolves. Any failure (non-ok response, empty list, or thrown error) silently keeps the static
seed, so the carousel always works. React Query is not used — it's a plain fetch inside a
useEffect with a cancelled guard.
getProductVideos() fetch is tagged
["videos"] with revalidate: 60 * 60. (Note: the workspace videos API routes do not
currently call revalidateTag("videos"), so a published change surfaces when the 1-hour TTL
elapses rather than instantly.)playbackId is required. Server-side mapper drops any CPT entry without one (see
videos-server.ts:97-98).featured should be true. The picker uses the first match; backstop
warning in the workspace UI is a TODO.videoplayer snippet block is
how Mux videos get embedded inside blog posts (different surface from this gallery)VideoObject JSON-LD spec