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 && ( + {`Video + )} + {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 && (