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.
PRODUCT_VIDEO_CATEGORIES in lib/data/product-videos.ts
is the single source of truth for video categories. Array order is display order.
export const PRODUCT_VIDEO_CATEGORIES = [
{ value: "dispatch", label: "Dispatch" },
{ value: "endpoint", label: "Endpoint" },
{ value: "platform", label: "Platform" },
{ value: "integrations-use-cases", label: "Integrations & Use Cases" },
{ value: "events", label: "Events" },
{ value: "company", label: "Company" },
] as const satisfies readonly { readonly value: string; readonly label: string }[];
export type ProductVideoCategory = (typeof PRODUCT_VIDEO_CATEGORIES)[number]["value"];
Everything downstream derives from it: the union type, PRODUCT_VIDEO_CATEGORY_LABELS,
isProductVideoCategory(), normalizeProductVideoCategory(), the workspace editor's
<Select>, the gallery's filter pills, and the Zod enum that guards the write API.
Adding a category is a one-line edit to this array — no other file changes.
Warning
This used to be four hand-maintained copies, and they drifted. The workspace editor's copy
sat three categories behind the type, so its load mapper (find(...)?.value || "platform")
silently reset any Dispatch, Endpoint or Integrations-&-Use-Cases video to Platform — and
the next save persisted that. If you ever find yourself re-declaring this list, don't.
tests/lib/product-video-content.test.ts
pins the invariant.
integrations and use-cases were merged into integrations-use-cases while each held too
few videos to earn its own filter chip. Historical CPT rows still carry the old values, so
normalizeProductVideoCategory() maps them onto the merged bucket on read rather than
dumping them to the default. To split them apart again: add both entries back to the array
and delete the matching entries from LEGACY_CATEGORY_ALIASES.
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 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"
thumbnailUrl?: string; // Optional poster override — replaces the Mux frame
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).
Each row shows a 64×36 poster between the drag handle and the title. The URL is resolved
server-side in the list route
via productVideoPosterUrl(), so the precedence rule — custom thumbnailUrl wins, else the Mux
frame at 0:05, else a placeholder icon — lives in one place and matches what the gallery renders.
This is why listCptItems takes { includeContent: true }: the poster lives in the content blob,
which is the largest field on a row, so it is opt-in and the other four CPT lists do not pay for it.
The generic cptUpsertSchema types content as unknown, because every CPT stores a
different payload. Videos pin theirs in
lib/data/product-video-content.ts, which
exports two schemas on purpose:
| Schema | Used by | Behaviour |
|---|---|---|
productVideoContentWriteSchema | apps/workspace/app/api/workspace/videos/schema.ts → POST + PUT | Strict. Rejects an unknown category, a missing playbackId, over-long fields. A tab left open across a deploy cannot write a payload built from a stale vocabulary — it gets a 422. |
productVideoContentReadSchema | lib/data/videos-server.ts | Lenient. Remaps retired categories, drops unrecognised related-link kinds, collapses blank optionals. Only a missing playbackId makes a row unusable, and the reader skips just that row. |
The asymmetry is deliberate. A bad write should fail loudly at the boundary; a bad row already in the database must never take down the whole gallery.
Workspace edit form ──┐
│ PUT /api/workspace/videos/<id>
│ ├─ videoUpdateSchema
│ └─ productVideoContentWriteSchema (strict — 422 on bad input)
▼
WP video CPT (private)
│
│ GET /wp-json/wp/v2/video?status=publish
│ └─ productVideoContentReadSchema (lenient — skips bad rows)
▼
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 + custom thumbnail URL + YouTube ID + duration + category + tags + featured toggle + related links. Imports its category and related-link vocabulary from lib/data/product-videos.ts; declares neither locally. |
| apps/workspace/app/api/workspace/videos/schema.ts | videoCreateSchema / videoUpdateSchema — cptUpsertSchema with content pinned to the video contract |
| apps/workspace/app/api/workspace/videos/route.ts | GET list, POST create (validates via videoCreateSchema) |
| apps/workspace/app/api/workspace/videos/[id]/route.ts | GET, PUT (validates via videoUpdateSchema), 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 | Canonical vocabulary (PRODUCT_VIDEO_CATEGORIES, related-link kinds, normalizeProductVideoCategory) + ProductVideo type + the static fallback array. |
| lib/data/product-video-content.ts | Zod read + write contracts for the CPT content blob. |
| tests/lib/product-video-content.test.ts | Pins the vocabulary invariant and both schemas' failure modes. |
Select rows in the workspace list and a toolbar appears above the table.
| Action | Applies to | Implementation |
|---|---|---|
| Publish / Unpublish / Private | WordPress status | bulkSetCptStatus — generic, any CPT |
| Move to trash | force=false, same as single delete | bulkDeleteCptItems — generic, any CPT |
| Set category | content.category | bulkPatchCptContent + a video-specific patch |
| Add / Remove tags | content.tags | bulkPatchCptContent + a video-specific patch |
Everything routes through POST /api/workspace/videos/bulk,
whose body is a Zod discriminated union on action
(schema.ts). Trashing is gated on
org:content:delete or org:content:edit, matching the single-item route; everything else
needs org:content:edit.
Status and delete are WordPress-native, so they live in cpt.service and any CPT module gets them
by calling the primitive. Category and tags are video content fields — industries and use cases
store entirely different shapes — so those are defined in the videos route. The shared seam is
bulkPatchCptContent(cfg, ids, patch): the service does the read-modify-write and knows nothing
about any module's schema, while the module supplies a closure that does.
The selection UI is shared the same way —
useRowSelection and
BulkActionBar are
module-agnostic; only the buttons inside the bar are video-specific.
Bulk actions report, they do not throw
With 30 rows selected, failing the entire call because row 17 was deleted in another tab would be
worse than applying the other 29 and saying so. Every primitive returns
{ succeeded: number[], failed: { id, reason }[] }, the UI surfaces a partial result as a partial
result, and revalidateTag fires once at the end — and only if something actually changed.
Each content action re-validates the patched blob through productVideoContentWriteSchema
before writing. A bulk edit is the easiest way to corrupt many records at once, so it gets the
strictest check, not the loosest; a row whose content cannot survive the patch fails alone.
Two limits worth knowing: CPT_BULK_MAX_ITEMS caps one request at 50 (each item costs one or two
sequential WordPress round-trips inside a serverless time budget), and drag-to-reorder is disabled
while any row is selected, since the two gestures fight over the same pointer.
scripts/seed-videos.ts loads a JSON array of
ProductVideo objects straight into the CPT.
npx tsx scripts/seed-videos.ts --dry-run # validate + report, write nothing
npx tsx scripts/seed-videos.ts # import the static fallback catalog
npx tsx scripts/seed-videos.ts path/to/videos.json # import your own JSON
npx tsx scripts/seed-videos.ts videos.json --status=draft
{ "videos": [...] } envelope that
/api/videos/list emits — so you can pipe the
live list back in after editing it. With no path, it imports PRODUCT_VIDEOS.slug and updated in place, never duplicated.[ORDER:nnn] excerpt marker. Re-import
a reordered file to re-order the gallery.productVideoContentWriteSchema — the same
contract the API enforces — and the script validates the whole file before writing
anything, so you never get a half-imported gallery.publish, because the reader only picks up publish in production.
Use --status=draft to stage.It talks to the WordPress REST API directly rather than calling createCptItem, because that
service calls revalidateTag(), which throws outside a Next.js request context. The
consequence: "videos" is not in the
/api/revalidate allowlist, so the public gallery
picks up a script import on its 1-hour ISR window — or immediately if you open and save any
video in the workspace afterwards.
getProductVideos() returns a source of "wordpress" | "fallback":
| Source | When | Effect |
|---|---|---|
"wordpress" | CPT returns ≥1 renderable video | Live data |
"fallback" | Fetch is not ok, every row fails validation, or an unexpected throw is caught | Static PRODUCT_VIDEOS array; every path logs through logger |
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.
Note
PRODUCT_VIDEOS is not an edit surface. It is the cold-start catalog for a WordPress
outage. Editing it by hand does not change what visitors see whenever the CPT is reachable —
which is nearly always. Add and edit videos in the workspace at /videos.
The navbar carousel progressively enhances the same way: it renders PRODUCT_VIDEOS on
first paint, then swaps in the live list once /api/videos/list resolves
(Navbar.tsx). It is a client component, so it cannot
await the CPT before painting — the static seed is what prevents an empty carousel on load.
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