diff --git a/RELEASE.rst b/RELEASE.rst
index 62d4620780..e7c0865401 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,17 @@
Release Notes
=============
+Version 0.77.2
+--------------
+
+- Fix flaky url-matching tests with unique factory urls (#3724)
+- Add course code to learning resource embedding context (#3700)
+- Sort learning resources by denormalized view_count instead of live Count() (#3711)
+- refactor: V1 — Clean up and de-duplicate the VideoPlaylistCollectionPage folder (#3706)
+- added a fallback to populate run readable ids for contentfiles withou… (#3719)
+- Include facets from urls in channel pages (#3695)
+- mitxonline course numbers (#3703)
+
Version 0.77.1 (Released August 05, 2026)
--------------
diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.test.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.test.tsx
index 57dbaad185..796a61d6fc 100644
--- a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.test.tsx
+++ b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.test.tsx
@@ -332,6 +332,62 @@ describe("ChannelSearch", () => {
},
)
+ test("Shows and aggregates a facet that is in the URL but not shown by default", async () => {
+ const { channel } = setMockApiResponses({
+ channelPatch: { channel_type: ChannelTypeEnum.Unit },
+ search: {
+ count: 700,
+ metadata: {
+ aggregations: {
+ level: [{ key: "graduate", doc_count: 100 }],
+ },
+ suggestions: [],
+ },
+ },
+ })
+
+ renderWithProviders(, {
+ url: `/c/${channel.channel_type}/${channel.name}/?level=graduate`,
+ })
+
+ await waitFor(() => {
+ expect(makeRequest.mock.calls.length > 0).toBe(true)
+ })
+
+ // "level" is not a default facet for this channel type, but it is requested
+ // as an aggregation because it is present in the URL.
+ const apiSearchParams = getLastApiSearchParams()
+ expect(apiSearchParams.getAll("aggregations")).toContain("level")
+
+ // ...and it renders as a facet.
+ const facetsContainer = screen.getByTestId("facets-container")
+ await within(facetsContainer).findByText("Level")
+ })
+
+ test("Does not duplicate a facet already shown by default as an extra URL facet", async () => {
+ const { channel } = setMockApiResponses({
+ channelPatch: { channel_type: ChannelTypeEnum.Unit },
+ search: {
+ count: 700,
+ metadata: {
+ aggregations: {
+ topic: [{ key: "physics", doc_count: 100 }],
+ },
+ suggestions: [],
+ },
+ },
+ })
+
+ // "topic" is a default facet for Unit channels and is also present in the
+ // URL; it must appear exactly once.
+ renderWithProviders(, {
+ url: `/c/${channel.channel_type}/${channel.name}/?topic=physics`,
+ })
+
+ const facetsContainer = await screen.findByTestId("facets-container")
+ expect(await within(facetsContainer).findAllByText("Topic")).toHaveLength(1)
+ })
+
test("Submitting search text updates URL correctly", async () => {
const resources = factories.learningResources.resources({
count: 10,
diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx
index 149401d498..48a962b0e9 100644
--- a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx
+++ b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx
@@ -57,8 +57,20 @@ const ChannelSearch: React.FC = ({
const { facetNames, facetManifest } = useMemo(
() =>
- getFacets(channelType, offerors, constantSearchParams, resourceTypeGroup),
- [offerors, channelType, constantSearchParams, resourceTypeGroup],
+ getFacets(
+ channelType,
+ offerors,
+ constantSearchParams,
+ resourceTypeGroup,
+ searchParams,
+ ),
+ [
+ offerors,
+ channelType,
+ constantSearchParams,
+ resourceTypeGroup,
+ searchParams,
+ ],
)
const setPage = useCallback(
diff --git a/frontends/main/src/app-pages/ChannelPage/searchRequests.ts b/frontends/main/src/app-pages/ChannelPage/searchRequests.ts
index 66b4b45d59..bd158bff71 100644
--- a/frontends/main/src/app-pages/ChannelPage/searchRequests.ts
+++ b/frontends/main/src/app-pages/ChannelPage/searchRequests.ts
@@ -8,6 +8,7 @@ import type {
import { LearningResourceOfferor } from "api"
import { ChannelTypeEnum } from "api/v0"
import { getFacetManifest } from "@/page-components/SearchDisplay/getFacetManifest"
+import { getExtraFacetNames } from "@/app-pages/SearchPage/searchRequests"
export const getConstantSearchParams = (searchFilter?: string) => {
const searchParams: Facets & BooleanFacets = {}
@@ -63,9 +64,16 @@ const getFacetManifestForChannelType = (
offerors: Record,
constantSearchParams: Facets,
resourceTypeGroup: string | null,
+ extraFacetNames: string[] = [],
): FacetManifest => {
- const facets = FACETS_BY_CHANNEL_TYPE[channelType] || []
- return getFacetManifest(offerors, resourceTypeGroup)
+ // Facets shown by default for this channel type plus any additional facets
+ // present in the URL. Facets hard-coded by the channel config
+ // (constantSearchParams) are filtered out below.
+ const facets = [
+ ...(FACETS_BY_CHANNEL_TYPE[channelType] || []),
+ ...extraFacetNames,
+ ]
+ return getFacetManifest(offerors, resourceTypeGroup, extraFacetNames)
.filter(
(facetSetting) =>
!Object.keys(constantSearchParams).includes(facetSetting.name) &&
@@ -81,12 +89,24 @@ export const getFacets = (
offerors: Record,
constantSearchParams: Facets,
resourceTypeGroup: string | null,
+ searchParams?: URLSearchParams,
) => {
+ // Facets already surfaced by the channel type or pinned by the channel config
+ // should not be duplicated as extra facets from the URL.
+ const baseFacetNames = [
+ ...(FACETS_BY_CHANNEL_TYPE[channelType] || []),
+ ...Object.keys(constantSearchParams),
+ ] as UseResourceSearchParamsProps["facets"]
+ const extraFacetNames = searchParams
+ ? (getExtraFacetNames(searchParams, baseFacetNames) ?? [])
+ : []
+
const facetManifest = getFacetManifestForChannelType(
channelType,
offerors,
constantSearchParams,
resourceTypeGroup,
+ extraFacetNames,
)
const facetNames = Array.from(
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/FeaturedVideo.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/FeaturedVideo.tsx
index 7e2f62a318..5cea5f2bbd 100644
--- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/FeaturedVideo.tsx
+++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/FeaturedVideo.tsx
@@ -7,6 +7,7 @@ import VideoContainer from "./VideoContainer"
import { RiPlayFill } from "@remixicon/react"
import { formatDurationClockTime } from "ol-utilities"
import type { VideoResource } from "api/v1"
+import { DurationBadge } from "./shared.styled"
const PLACEHOLDER_IMG = "/images/mit-open-learning-logo.svg"
@@ -56,6 +57,8 @@ const ImageWrapper = styled(Link, {
}),
}))
+// Distinct from the shared `PlayOverlay`: this one is always visible and scales
+// on hover (see ImageWrapper above) rather than fading a scrim in.
const PlayOverlay = styled.div({
position: "absolute",
inset: 0,
@@ -76,18 +79,6 @@ const PlayCircle = styled.div({
justifyContent: "center",
})
-const DurationBadge = styled.span(({ theme }) => ({
- ...theme.typography.body3,
- position: "absolute",
- bottom: 0,
- right: 0,
- backgroundColor: theme.custom.colors.darkGray2,
- color: "#fff",
- fontWeight: theme.typography.fontWeightMedium,
- padding: "8px",
- zIndex: 1,
-}))
-
const TextSide = styled.div(({ theme }) => ({
[theme.breakpoints.down("sm")]: {
padding: "16px 0 0",
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx
new file mode 100644
index 0000000000..8a51b0dfc8
--- /dev/null
+++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MoreFromPlaylist.tsx
@@ -0,0 +1,134 @@
+import React from "react"
+import Image from "next/image"
+import { Skeleton } from "ol-components"
+import { formatDurationClockTime } from "ol-utilities"
+import type { VideoResource } from "api/v1"
+import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls"
+import * as Styled from "./VideoDetailPage.styled"
+
+type MoreFromPlaylistProps = {
+ playlistId: number
+ /** Display name of the playlist, used in headings and labels. */
+ playlistLabel: string
+ playlistTitle?: string
+ /** Sibling videos to list, already filtered and capped by the caller. */
+ videos: VideoResource[]
+ /** Total videos in the playlist, used to decide whether to link to the rest. */
+ totalVideos: number
+ isLoading: boolean
+}
+
+/** One row: thumbnail with duration badge and hover overlay, plus title/description. */
+const MoreFromPlaylistItem: React.FC<{
+ video: VideoResource
+ playlistId: number
+}> = ({ video, playlistId }) => {
+ const duration = video.video?.duration
+ ? formatDurationClockTime(video.video.duration)
+ : null
+ const imageUrl = video.image?.url ?? null
+ const topicNames = (video.topics ?? [])
+ .map((topic) => topic.name)
+ .filter(Boolean)
+ .join(" · ")
+
+ return (
+
+
+ {imageUrl && (
+
+ )}
+ {duration && (
+ {duration}
+ )}
+
+
+
+
+
+
+ {video.title}
+
+ {video.description && (
+
+ )}
+
+
+ )
+}
+
+/**
+ * "More from " — the sibling-video list at the foot of the video detail
+ * page. Renders nothing once loaded if the playlist has no other videos.
+ */
+const MoreFromPlaylist: React.FC = ({
+ playlistId,
+ playlistLabel,
+ playlistTitle,
+ videos,
+ totalVideos,
+ isLoading,
+}) => {
+ if (isLoading) {
+ return (
+ <>
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+ >
+ )
+ }
+
+ if (videos.length === 0) return null
+
+ // +1 accounts for the video currently being watched, which is excluded from
+ // `videos` — without it a fully-listed playlist would still offer "View all".
+ const hasMore = totalVideos > videos.length + 1
+
+ return (
+ <>
+ More from {playlistLabel}
+
+ {videos.map((video) => (
+
+
+
+
+ ))}
+
+ {hasMore && (
+
+ View all in {playlistLabel} →
+
+ )}
+ >
+ )
+}
+
+export default MoreFromPlaylist
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/ShareDialog.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/ShareDialog.tsx
deleted file mode 100644
index 25acfe8b76..0000000000
--- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/ShareDialog.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from "@/components/ShareDialog/ShareDialog"
-export type { ShareDialogProps } from "@/components/ShareDialog/ShareDialog"
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCard.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCard.tsx
index 6e60a03ce7..96510acf63 100644
--- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCard.tsx
+++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCard.tsx
@@ -3,8 +3,13 @@ import Image from "next/image"
import Link from "next/link"
import { Typography, styled, theme, Skeleton } from "ol-components"
import { formatDurationClockTime } from "ol-utilities"
-import { RiPlayCircleFill } from "@remixicon/react"
import type { VideoResource } from "api/v1"
+import {
+ DurationBadge,
+ PlayOverlay,
+ PlayIcon,
+ ThumbnailWrapper,
+} from "./shared.styled"
const PLACEHOLDER_IMG = "/images/mit-open-learning-logo.svg"
@@ -35,19 +40,6 @@ const VideoCardItem = styled(Link)({
},
})
-const ThumbnailWrapper = styled.div({
- position: "relative",
- flexShrink: 0,
- width: 160,
- aspectRatio: "16/9",
- overflow: "hidden",
- backgroundColor: theme.custom.colors.black,
-
- [theme.breakpoints.down("sm")]: {
- width: "100%",
- },
-})
-
const ThumbnailImage = styled(Image)(({ theme }) => ({
objectFit: "cover",
width: "160px",
@@ -57,30 +49,6 @@ const ThumbnailImage = styled(Image)(({ theme }) => ({
},
}))
-const DurationBadge = styled.span(({ theme }) => ({
- ...theme.typography.body3,
- position: "absolute",
- bottom: 0,
- right: 0,
- backgroundColor: theme.custom.colors.darkGray2,
- color: "#fff",
- fontWeight: theme.typography.fontWeightMedium,
- padding: "8px",
- zIndex: 1,
-}))
-
-const PlayOverlay = styled.div({
- position: "absolute",
- inset: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- color: "#fff",
- opacity: 0,
- transition: "opacity 0.2s",
- backgroundColor: "rgba(0, 0, 0, 0.18)",
-})
-
const CardContent = styled.div({
flex: 1,
display: "flex",
@@ -107,11 +75,6 @@ const CardTitle = styled(Typography)(({ theme }) => ({
},
}))
-const PlayIcon = styled(RiPlayCircleFill)({
- width: 36,
- height: 36,
-})
-
const CardMetaRow = styled.div({
display: "flex",
alignItems: "flex-start",
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.styled.ts b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.styled.ts
new file mode 100644
index 0000000000..d68df260c0
--- /dev/null
+++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.styled.ts
@@ -0,0 +1,234 @@
+import Link from "next/link"
+import { Typography, styled, theme } from "ol-components"
+import VideoResourcePlayer from "./VideoResourcePlayer"
+
+// Primitives shared with the other video pages, re-exported so consumers of this
+// module have a single import for the page's styles.
+export {
+ SkipLinksNav,
+ StyledBreadcrumbs,
+ ScreenReaderOnly,
+ DurationBadge,
+ PlayOverlay,
+ PlayIcon,
+ ThumbnailWrapper,
+ VideoTitle,
+} from "./shared.styled"
+
+// ── Page shell ──
+
+export const PageWrapper = styled.div({
+ backgroundColor: "#fff",
+ minHeight: "100vh",
+})
+
+export const BreadcrumbBar = styled.div(({ theme }) => ({
+ padding: "18px 0 2px 0",
+ borderBottom: `1px solid ${theme.custom.colors.red}`,
+ [theme.breakpoints.down("sm")]: {
+ padding: "12px 0 0 0",
+ },
+}))
+
+export const ContentArea = styled.div(({ theme }) => ({
+ padding: "56px 0 80px",
+ [theme.breakpoints.down("sm")]: {
+ padding: "32px 0 80px",
+ },
+}))
+
+export const CategoryLabel = styled(Link)(({ theme }) => ({
+ display: "block",
+ ...theme.typography.body3,
+ fontWeight: theme.typography.fontWeightBold,
+ color: theme.custom.colors.red,
+ textTransform: "uppercase",
+ letterSpacing: "1.92px",
+ marginBottom: "8px",
+ fontSize: "12px",
+ fontStyle: "normal",
+ lineHeight: "150%" /* 18px */,
+ "&:hover": {
+ textDecoration: "underline",
+ },
+}))
+
+// ── Title / meta row ──
+
+export const VideoShareSection = styled("div")({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ flexWrap: "wrap",
+ gap: "8px",
+ marginBottom: "24px",
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "16px",
+ },
+})
+
+export const MetaRow = styled.div({
+ ...theme.typography.body2,
+ color: theme.custom.colors.darkGray1,
+})
+
+export const TopicText = styled.span(({ theme }) => ({
+ color: theme.custom.colors.silverGrayDark,
+ ...theme.typography.body2,
+ lineHeight: "22px",
+ paddingLeft: "8px",
+}))
+
+export const DurationText = styled.span(({ theme }) => ({
+ color: theme.custom.colors.black,
+ ...theme.typography.body2,
+ lineHeight: "22px",
+ fontWeight: theme.typography.fontWeightBold,
+}))
+
+// ── Player / description ──
+
+export const StyledVideoResourcePlayer = styled(VideoResourcePlayer)(
+ ({ theme }) => ({
+ [theme.breakpoints.down("sm")]: {
+ marginTop: "0",
+ },
+ }),
+)
+
+export const BorderLine = styled.div(({ theme }) => ({
+ borderBottom: `4px solid ${theme.custom.colors.darkGray2}`,
+ marginBottom: "40px",
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "24px",
+ },
+}))
+
+export const DescriptionText = styled(Typography)(({ theme }) => ({
+ ...theme.typography.body1,
+ color: theme.custom.colors.darkGray2,
+ marginBottom: "22px",
+ fontSize: "18px",
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: "30px",
+ [theme.breakpoints.down("sm")]: {
+ fontSize: "16px",
+ lineHeight: "28px",
+ marginBottom: "24px",
+ },
+}))
+
+// ── "More from playlist" list ──
+
+export const MoreFromTitle = styled(Typography)(({ theme }) => ({
+ ...theme.typography.body3,
+ fontWeight: theme.typography.fontWeightBold,
+ textTransform: "uppercase",
+ color: theme.custom.colors.black,
+ padding: "32px 0",
+ lineHeight: "150%",
+ letterSpacing: "1.92px",
+ [theme.breakpoints.down("sm")]: {
+ padding: "24px 0",
+ },
+}))
+
+export const MoreFromList = styled.div({
+ display: "flex",
+ flexDirection: "column",
+})
+
+export const MoreFromItem = styled(Link)({
+ display: "flex",
+ alignItems: "flex-start",
+ gap: "24px",
+ padding: "24px 0",
+ borderBottom: `1px solid ${theme.custom.colors.lightGray2}`,
+ textDecoration: "none",
+ "&:hover .mf-title": { color: theme.custom.colors.red },
+
+ "&:hover .video-card-title, &:focus-visible .video-card-title": {
+ color: theme.custom.colors.red,
+ },
+
+ "&:hover .play-overlay": {
+ opacity: 0.5,
+ },
+
+ "&:focus-visible .play-overlay": {
+ opacity: 0.5,
+ },
+
+ "&:first-child": {
+ padding: "0 0 24px 0",
+ },
+
+ [theme.breakpoints.down("sm")]: {
+ flexDirection: "column",
+ },
+})
+
+export const MoreFromTextSide = styled.div(({ theme }) => ({
+ flex: 1,
+ minWidth: 0,
+ paddingTop: "17px",
+ [theme.breakpoints.down("sm")]: {
+ paddingTop: 0,
+ },
+}))
+
+export const MoreFromItemTitle = styled(Typography)({
+ ...theme.typography.subtitle2,
+ fontWeight: theme.typography.fontWeightBold,
+ color: theme.custom.colors.black,
+ transition: "color 0.15s",
+ marginBottom: "4px",
+ fontSize: "20px",
+ lineHeight: "26px" /* 130% */,
+})
+
+export const MoreFromItemMeta = styled(Typography)({
+ ...theme.typography.body2,
+ color: theme.custom.colors.silverGrayDark,
+ overflow: "hidden",
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical",
+ lineHeight: "22px",
+})
+
+export const SeeAllLink = styled(Link)(({ theme }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ marginTop: "40px",
+ ...theme.typography.body1,
+ color: theme.custom.colors.red,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: "150%",
+ textDecoration: "none",
+ "&:hover": { textDecoration: "underline" },
+ [theme.breakpoints.down("sm")]: {
+ marginTop: "28px",
+ },
+}))
+
+/** Mobile-only gap between "More from" rows; collapses to nothing on desktop. */
+export const SpacerBlock = styled.div(({ theme }) => ({
+ "& .spacer-block": {
+ display: "none",
+ },
+ [theme.breakpoints.down("sm")]: {
+ height: "8px",
+ "& .spacer-block": {
+ display: "block",
+ },
+ },
+}))
+
+/** Row placeholder matching a "More from" item while the playlist loads. */
+export const MoreFromSkeletonRow = styled.div(({ theme }) => ({
+ display: "flex",
+ gap: 16,
+ padding: "16px 0",
+ borderBottom: `1px solid ${theme.custom.colors.lightGray2}`,
+}))
diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx
index 4c3b1661d3..84e73c2518 100644
--- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx
+++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx
@@ -2,13 +2,10 @@
import { env } from "@/env"
import React, { useEffect, useRef } from "react"
-import Link from "next/link"
-import Image from "next/image"
-import { Typography, styled, theme, Skeleton, SkipLink } from "ol-components"
+import { Skeleton, SkipLink } from "ol-components"
import VideoContainer from "./VideoContainer"
-import { RiPlayCircleFill } from "@remixicon/react"
-import { SkipLinksNav, StyledBreadcrumbs } from "./shared.styled"
import VideoShareButton from "./VideoShareButton"
+import MoreFromPlaylist from "./MoreFromPlaylist"
import { useQuery } from "@tanstack/react-query"
import {
useLearningResourcesDetail,
@@ -19,284 +16,13 @@ import { VideoResourceResourceTypeEnum } from "api/v1"
import { formatDurationClockTime } from "ol-utilities"
import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls"
import { buildVideoStructuredData } from "./videoStructuredData"
-import VideoResourcePlayer from "./VideoResourcePlayer"
import type { VideoPlayerHandle } from "./VideoResourcePlayer"
+import * as Styled from "./VideoDetailPage.styled"
const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN")
-const PageWrapper = styled.div({
- backgroundColor: "#fff",
- minHeight: "100vh",
-})
-
-const BreadcrumbBar = styled.div(({ theme }) => ({
- padding: "18px 0 2px 0",
- borderBottom: `1px solid ${theme.custom.colors.red}`,
- [theme.breakpoints.down("sm")]: {
- padding: "12px 0 0 0",
- },
-}))
-
-const PlayIcon = styled(RiPlayCircleFill)({
- width: 36,
- height: 36,
-})
-
-const ContentArea = styled.div(({ theme }) => ({
- padding: "56px 0 80px",
- [theme.breakpoints.down("sm")]: {
- padding: "32px 0 80px",
- },
-}))
-
-const CategoryLabel = styled(Link)(({ theme }) => ({
- display: "block",
- ...theme.typography.body3,
- fontWeight: theme.typography.fontWeightBold,
- color: theme.custom.colors.red,
- textTransform: "uppercase",
- letterSpacing: "1.92px",
- marginBottom: "8px",
- fontSize: "12px",
- fontStyle: "normal",
- lineHeight: "150%" /* 18px */,
- "&:hover": {
- textDecoration: "underline",
- },
-}))
-
-const StyledVideoShareButton = styled(VideoShareButton)({
- height: "40px",
- padding: "18px 12px",
-})
-
-const VideoShareSection = styled("div")({
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- flexWrap: "wrap",
- gap: "8px",
- marginBottom: "24px",
- [theme.breakpoints.down("sm")]: {
- marginBottom: "16px",
- },
-})
-
-const VideoTitle = styled.h1(({ theme }) => ({
- ...theme.typography.h2,
- fontWeight: theme.typography.fontWeightBold,
- color: theme.custom.colors.black,
- margin: "0 0 24px",
- "&:focus": { outline: "none" },
- fontSize: "44px",
- fontStyle: "normal",
- lineHeight: "120%" /* 52.8px */,
- letterSpacing: "-0.88px",
- [theme.breakpoints.down("sm")]: {
- ...theme.typography.h3,
- margin: "0 0 14px",
- letterSpacing: "inherit",
- },
-}))
-
-const MetaRow = styled.div({
- ...theme.typography.body2,
- color: theme.custom.colors.darkGray1,
-})
-
-const StyledVideoResourcePlayer = styled(VideoResourcePlayer)(({ theme }) => ({
- [theme.breakpoints.down("sm")]: {
- marginTop: "0",
- },
-}))
-
-const DescriptionText = styled(Typography)(({ theme }) => ({
- ...theme.typography.body1,
- color: theme.custom.colors.darkGray2,
- marginBottom: "22px",
- fontSize: "18px",
- fontWeight: theme.typography.fontWeightMedium,
- lineHeight: "30px",
- [theme.breakpoints.down("sm")]: {
- fontSize: "16px",
- lineHeight: "28px",
- marginBottom: "24px",
- },
-}))
-
-const MoreFromTitle = styled(Typography)(({ theme }) => ({
- ...theme.typography.body3,
- fontWeight: theme.typography.fontWeightBold,
- textTransform: "uppercase",
- color: theme.custom.colors.black,
- padding: "32px 0",
- lineHeight: "150%",
- letterSpacing: "1.92px",
- [theme.breakpoints.down("sm")]: {
- padding: "24px 0",
- },
-}))
-
-const MoreFromList = styled.div({
- display: "flex",
- flexDirection: "column",
-})
-
-const BorderLine = styled.div(({ theme }) => ({
- borderBottom: `4px solid ${theme.custom.colors.darkGray2}`,
- marginBottom: "40px",
- [theme.breakpoints.down("sm")]: {
- marginBottom: "24px",
- },
-}))
-
-const MoreFromItem = styled(Link)({
- display: "flex",
- alignItems: "flex-start",
- gap: "24px",
- padding: "24px 0",
- borderBottom: `1px solid ${theme.custom.colors.lightGray2}`,
- textDecoration: "none",
- "&:hover .mf-title": { color: theme.custom.colors.red },
-
- "&:hover .video-card-title, &:focus-visible .video-card-title": {
- color: theme.custom.colors.red,
- },
-
- "&:hover .play-overlay": {
- opacity: 0.5,
- },
-
- "&:focus-visible .play-overlay": {
- opacity: 0.5,
- },
-
- "&:first-child": {
- padding: "0 0 24px 0",
- },
-
- [theme.breakpoints.down("sm")]: {
- flexDirection: "column",
- },
-})
-
-const MoreFromThumbnailWrapper = styled.div(({ theme }) => ({
- position: "relative",
- flexShrink: 0,
- width: 160,
- aspectRatio: "16/9",
- backgroundColor: theme.custom.colors.black,
- overflow: "hidden",
- [theme.breakpoints.down("sm")]: {
- width: "100%",
- },
-}))
-
-const DurationBadge = styled.span(({ theme }) => ({
- ...theme.typography.body3,
- position: "absolute",
- bottom: 0,
- right: 0,
- backgroundColor: theme.custom.colors.darkGray2,
- color: "#fff",
- fontWeight: theme.typography.fontWeightMedium,
- padding: "4px 6px",
- zIndex: 1,
-}))
-
-const MoreFromTextSide = styled.div(({ theme }) => ({
- flex: 1,
- minWidth: 0,
- paddingTop: "17px",
- [theme.breakpoints.down("sm")]: {
- paddingTop: 0,
- },
-}))
-
-const MoreFromItemTitle = styled(Typography)({
- ...theme.typography.subtitle2,
- fontWeight: theme.typography.fontWeightBold,
- color: theme.custom.colors.black,
- transition: "color 0.15s",
- marginBottom: "4px",
- fontSize: "20px",
- lineHeight: "26px" /* 130% */,
-})
-
-const MoreFromItemMeta = styled(Typography)({
- ...theme.typography.body2,
- color: theme.custom.colors.silverGrayDark,
- overflow: "hidden",
- display: "-webkit-box",
- WebkitLineClamp: 2,
- WebkitBoxOrient: "vertical",
- lineHeight: "22px",
-})
-
-const SeeAllLink = styled(Link)(({ theme }) => ({
- display: "inline-flex",
- alignItems: "center",
- marginTop: "40px",
- ...theme.typography.body1,
- color: theme.custom.colors.red,
- fontWeight: theme.typography.fontWeightMedium,
- lineHeight: "150%",
- textDecoration: "none",
- "&:hover": { textDecoration: "underline" },
- [theme.breakpoints.down("sm")]: {
- marginTop: "28px",
- },
-}))
-
-const SpacerBlock = styled.div(({ theme }) => ({
- "& .spacer-block": {
- display: "none",
- },
- [theme.breakpoints.down("sm")]: {
- height: "8px",
- "& .spacer-block": {
- display: "block",
- },
- },
-}))
-
-const TopicText = styled.span(({ theme }) => ({
- color: theme.custom.colors.silverGrayDark,
- ...theme.typography.body2,
- lineHeight: "22px",
- paddingLeft: "8px",
-}))
-
-const DurationText = styled.span(({ theme }) => ({
- color: theme.custom.colors.black,
- ...theme.typography.body2,
- lineHeight: "22px",
- fontWeight: theme.typography.fontWeightBold,
-}))
-
-const PlayOverlay = styled.div({
- position: "absolute",
- inset: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- color: "#fff",
- opacity: 0,
- transition: "opacity 0.2s",
- backgroundColor: "rgba(0, 0, 0, 0.18)",
-})
-
-const ScreenReaderOnly = styled.span({
- position: "absolute",
- width: 1,
- height: 1,
- padding: 0,
- margin: -1,
- overflow: "hidden",
- clip: "rect(0, 0, 0, 0)",
- whiteSpace: "nowrap",
- border: 0,
-})
+/** How many sibling videos the "More from" list shows at most. */
+const MORE_FROM_LIMIT = 5
type VideoDetailPageProps = {
videoId: number
@@ -343,7 +69,7 @@ const VideoDetailPage: React.FC = ({
item.resource_type === VideoResourceResourceTypeEnum.Video &&
item.id !== videoId,
)
- .slice(0, 5)
+ .slice(0, MORE_FROM_LIMIT)
const totalPlaylistVideos = (playlistItems ?? []).filter(
(item) => item.resource_type === VideoResourceResourceTypeEnum.Video,
@@ -375,7 +101,7 @@ const VideoDetailPage: React.FC = ({
const structuredData = !isLoading ? buildVideoStructuredData(video) : null
return (
-
+
{structuredData && (