feat: video playlist page design implementation#3135
Conversation
OpenAPI Changes27 changes: 0 error, 22 warning, 5 info Unexpected changes? Ensure your branch is up-to-date with |
3bdf681 to
1905541
Compare
8649d77 to
39e00af
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces an initial user-facing “video playlist” page in the Next.js App Router, backed by new API client/query support for video playlists and gated behind a PostHog feature flag.
Changes:
- Added a new
/playlist/[id]route that server-prefetches playlist + item data and hydrates a new client-side playlist page. - Implemented new playlist UI components (header, featured video, collection list, modal player) including a video.js-based player with YouTube support.
- Extended the API client + react-query hooks to retrieve video playlist details, and added the new
video-playlist-pagefeature flag.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Locks new dependencies for video.js / YouTube tech and related transitive packages. |
| frontends/main/package.json | Adds video.js and videojs-youtube dependencies for the new player. |
| frontends/main/src/common/feature_flags.ts | Adds VideoPlaylistPage feature flag key. |
| frontends/main/src/app/playlist/[id]/page.tsx | New App Router route for playlist detail with React Query SSR hydration. |
| frontends/main/src/app-pages/VideoPage/VideoPage.tsx | Client playlist page: feature flag gating, queries, featured video selection, modal state. |
| frontends/main/src/app-pages/VideoPage/VideoPageHeader.tsx | Playlist header UI (title/description). |
| frontends/main/src/app-pages/VideoPage/FeaturedVideo.tsx | Featured video hero section with play interaction. |
| frontends/main/src/app-pages/VideoPage/VideoCollection.tsx | Renders the remaining playlist videos as a list of cards. |
| frontends/main/src/app-pages/VideoPage/VideoCard.tsx | Video list item card with thumbnail, duration, and play affordance. |
| frontends/main/src/app-pages/VideoPage/VideoPlayerModal.tsx | Modal wrapper for playback and “no playable source” fallback. |
| frontends/main/src/app-pages/VideoPage/VideoJsPlayer.tsx | Video.js wrapper + source resolution for HLS/DASH/MP4/YouTube. |
| frontends/api/src/clients.ts | Adds and exports videoPlaylistsApi client. |
| frontends/api/src/hooks/learningResources/queries.ts | Adds videoPlaylistQueries.detail and query keys. |
| frontends/api/src/hooks/learningResources/index.ts | Re-exports videoPlaylistQueries for consumers. |
| await Promise.all([ | ||
| queryClient.prefetchQuery(videoPlaylistQueries.detail(playlistId)), | ||
| queryClient.prefetchQuery( | ||
| learningResourceQueries.items(playlistId, { | ||
| learning_resource_id: playlistId, | ||
| }), | ||
| ), | ||
| ]) | ||
|
|
There was a problem hiding this comment.
This page prefetches playlist data with prefetchQuery, but doesn't ensure a missing playlist returns a real 404 response. Consider using queryClient.fetchQueryOr404(videoPlaylistQueries.detail(playlistId)) for the playlist detail (similar to src/app/news/[slugOrId]/page.tsx) so a 404 from the API becomes a Next.js 404 page on initial load.
| await Promise.all([ | |
| queryClient.prefetchQuery(videoPlaylistQueries.detail(playlistId)), | |
| queryClient.prefetchQuery( | |
| learningResourceQueries.items(playlistId, { | |
| learning_resource_id: playlistId, | |
| }), | |
| ), | |
| ]) | |
| // Ensure a missing playlist results in a real Next.js 404 on initial load | |
| await queryClient.fetchQueryOr404(videoPlaylistQueries.detail(playlistId)) | |
| await queryClient.prefetchQuery( | |
| learningResourceQueries.items(playlistId, { | |
| learning_resource_id: playlistId, | |
| }), | |
| ) |
There was a problem hiding this comment.
Not sure about it need to think more on it
There was a problem hiding this comment.
this is a good idea
| notFound() | ||
| } | ||
| const isLoading = playlistLoading || itemsLoading | ||
| const videos = (items ?? []) as VideoResource[] |
There was a problem hiding this comment.
items is cast to VideoResource[] without checking the discriminator (e.g. resource_type). Since learningResourcesItemsList returns a generic learning resource union, this can break at runtime if a playlist contains a non-video item (or the API evolves). Prefer narrowing/filtering by resource_type === "video" (or using a video-playlist-specific endpoint/type) instead of a blanket cast.
| const videos = (items ?? []) as VideoResource[] | |
| const videos = (items ?? []).filter( | |
| (item: any): item is VideoResource => item?.resource_type === "video", | |
| ) |
| return ( | ||
| <ModalBackdrop | ||
| onClick={(e) => { | ||
| if (e.target === e.currentTarget) onClose() | ||
| }} | ||
| > | ||
| <ModalContent> | ||
| <ModalHeader> | ||
| <ModalTitle>{video.title}</ModalTitle> | ||
| <ModalCloseButton | ||
| type="button" | ||
| aria-label="Close video" | ||
| onClick={onClose} | ||
| > | ||
| <RiCloseLine /> | ||
| </ModalCloseButton> | ||
| </ModalHeader> | ||
| <PlayerWrapper> |
There was a problem hiding this comment.
The modal markup is missing common dialog accessibility attributes/behavior (e.g., role="dialog", aria-modal="true", and an aria-labelledby pointing at the title). Also consider handling Escape to close and trapping/restoring focus while the modal is open to keep keyboard navigation usable.
| "sharp": "0.34.4", | ||
| "slick-carousel": "^1.8.1", | ||
| "tiny-invariant": "^1.3.3", | ||
| "video.js": "8.23.7", |
There was a problem hiding this comment.
Pinning video.js to 8.23.7 causes two versions to be installed (8.23.7 and 8.23.8 via videojs-youtube), increasing bundle size. Consider aligning on a single version (e.g., bump to 8.23.8 or use a compatible range) and/or adding a Yarn resolutions entry to dedupe.
| "video.js": "8.23.7", | |
| "video.js": "^8.23.8", |
| const VideoPage: React.FC<VideoPageProps> = ({ playlistId }) => { | ||
| const [activeVideo, setActiveVideo] = useState<VideoResource | null>(null) | ||
|
|
||
| const openVideo = useCallback((resource: VideoResource) => { | ||
| setActiveVideo(resource) | ||
| }, []) | ||
|
|
||
| const closeVideo = useCallback(() => { | ||
| setActiveVideo(null) | ||
| }, []) |
There was a problem hiding this comment.
This new page introduces several behaviors that would benefit from automated tests (feature-flag gating without 404 flicker, rendering playlist header/content based on fetched data, and opening/closing the player modal). There are existing page/component tests under src/app-pages/ProductPages/*.test.tsx; adding similar tests for VideoPage would help prevent regressions.
| import VideoJsPlayer, { resolveVideoSources } from "./VideoJsPlayer" | ||
|
|
There was a problem hiding this comment.
video.js is a fairly large dependency; importing VideoJsPlayer eagerly means the playlist page bundle will include video.js even if the user never opens the modal. Consider lazy-loading the player with next/dynamic (or React.lazy) when the modal opens to keep initial JS/CSS payload smaller (similar to dynamic imports in app-pages/ChannelPage/ChannelPage.tsx).
| import VideoJsPlayer, { resolveVideoSources } from "./VideoJsPlayer" | |
| import dynamic from "next/dynamic" | |
| import { resolveVideoSources } from "./VideoJsPlayer" | |
| const VideoJsPlayer = dynamic(() => import("./VideoJsPlayer"), { | |
| ssr: false, | |
| loading: () => null, | |
| }) |
39e00af to
e2114b3
Compare
e2114b3 to
f2d37ca
Compare
f2d37ca to
d25941c
Compare
d25941c to
a6282ce
Compare
2ff7f4c to
5194a58
Compare
4cc560c to
f4d366b
Compare
| variant="light" | ||
| ancestors={[ | ||
| { href: "/", label: "Home" }, | ||
| { href: "/videos", label: "Videos" }, |
There was a problem hiding this comment.
This breadcrumb points to /videos, but there is no matching route in the app router. Clicking "Videos" leads to a dead page. Can we update this to a valid destination (or remove it) to avoid broken navigation?
There was a problem hiding this comment.
@zamanafzal Ferdi has provided me the design in which video is part of breadcrumb. I already asked him about it but he did not reply me let me ask him again where it should point to.
| <Container> | ||
| <StyledBreadcrumbs | ||
| variant="light" | ||
| ancestors={[{ href: `/playlist/${playlistId}`, label: "Playlist" }]} |
There was a problem hiding this comment.
Currently I have removed the detail page from this PR and moved it here #3173 so when we click on the video then for now we are showing 404 page which obviously will available in new PR
| <VideoTitle>{video?.title}</VideoTitle> | ||
| )} | ||
|
|
||
| {/* Description */} |
There was a problem hiding this comment.
nit: I think we should remove these comments.
There was a problem hiding this comment.
I have removed the videoDetailPage which will be available in new PR #3173
| }, | ||
| [theme.breakpoints.down("sm")]: { | ||
| padding: "16px 0 0", | ||
| letteSpacing: "inherit", |
There was a problem hiding this comment.
typo: letteSpacing should be letterSpacing.
| <Section> | ||
| <VideoContainer> | ||
| <FeaturedGrid> | ||
| <ImageWrapper onClick={() => onPlay(video)}> |
There was a problem hiding this comment.
Could we switch to semantic button/link behaviour (or add role/tabIndex + Enter/Space handlers) for accessibility parity?
There was a problem hiding this comment.
Semantic elements would be great!
| </ImageWrapper> | ||
|
|
||
| <TextSide> | ||
| <FeaturedTitle onClick={() => onPlay(video)}> |
| import videojs from "video.js" | ||
| import Player from "video.js/dist/types/player" | ||
| import "video.js/dist/video-js.css" | ||
| // Register YouTube tech so video.js can play youtube:// sources |
There was a problem hiding this comment.
I think this comment is a bit misleading. Code passes normal YouTube URLs (e.g. https://youtube.com/...), not necessarily youtube://
There was a problem hiding this comment.
I have removed this comment
| // Helper: extract instructor info | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function getInstructorInfo(video: VideoResource): { |
There was a problem hiding this comment.
How about renaming it to getPrimaryInstructorInfo for better clarity?
There was a problem hiding this comment.
I have removed the videoDetailPage from this PR
| } | ||
|
|
||
| const FeaturedVideo: React.FC<FeaturedVideoProps> = ({ videos, onPlay }) => { | ||
| const [currentIndex] = useState(0) |
There was a problem hiding this comment.
nit: currentIndex is always 0, so useState here adds cognitive overhead. Consider replacing with const video = videos[0] for clearer intent.
There was a problem hiding this comment.
Request: similar to Zaman's comment, change the videos prop to video. We are only using one. No need to pass the whole array. Confusing for "FeaturedVideo" to take an array, IMO.
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
This will be a great feature. I left some comments on naming, existing utility funcs that might help, queries, and questions about files that seem to be unused.
| const { data: playlistData, isLoading: playlistLoading } = useQuery( | ||
| videoPlaylistQueries.detail(playlistId), | ||
| ) | ||
| const playlist = playlistData as VideoPlaylistResource | undefined |
There was a problem hiding this comment.
Request: Let's remove this. The type is already VideoPlaylistResource | undefined, the cast does nothing.
| const { data: similarData, isLoading: similarLoading } = useQuery( | ||
| learningResourceQueries.vectorSimilar(playlistId), | ||
| ) |
There was a problem hiding this comment.
I noticed that:
- below you did
otherCollections.slice(0,6) - and there is filtering on the results of vectorSimilar
This has the downside that you're getting an unpredictable number of results. (E.g., if vectorSimilar returns mostly courses or podcasts or whatever, they are filtered out by your filter)
It would be better to let the APIs handle the trimming and filtering. You can do that by changing the vectorSimilar query...for some reason right now it only supports a single id param, but the API supports much more.
Suggest changing to:
--- a/frontends/api/src/hooks/learningResources/queries.ts
+++ b/frontends/api/src/hooks/learningResources/queries.ts
@@ -20,6 +20,7 @@ import type {
LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest,
VideoPlaylistResource,
+ LearningResourcesApiLearningResourcesVectorSimilarListRequest,
} from "../../generated/v1"
import type { VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest as VectorLearningResourcesSearchRetrieveRequest } from "../../generated/v0"
import { queryOptions } from "@tanstack/react-query"
@@ -42,10 +43,9 @@ const learningResourceKeys = {
detailsRoot: () => [...learningResourceKeys.root, "detail"],
detail: (id: number) => [...learningResourceKeys.detailsRoot(), id],
similar: (id: number) => [...learningResourceKeys.detail(id), "similar"],
- vectorSimilar: (id: number) => [
- ...learningResourceKeys.detail(id),
- "vector_similar",
- ],
+ vectorSimilar: (
+ params: LearningResourcesApiLearningResourcesVectorSimilarListRequest,
+ ) => [...learningResourceKeys.detail(params.id), "vector_similar", params],
itemsRoot: (id: number) => [...learningResourceKeys.detail(id), "items"],
items: (id: number, params: ItemsListRequest) => [
...learningResourceKeys.itemsRoot(id),
@@ -128,12 +128,14 @@ const learningResourceQueries = {
.learningResourcesSimilarList({ id })
.then((res) => res.data),
}),
- vectorSimilar: (id: number) =>
+ vectorSimilar: (
+ params: LearningResourcesApiLearningResourcesVectorSimilarListRequest,
+ ) =>
queryOptions({
- queryKey: learningResourceKeys.vectorSimilar(id),
+ queryKey: learningResourceKeys.vectorSimilar(params),
queryFn: () =>
learningResourcesApi
- .learningResourcesVectorSimilarList({ id })
+ .learningResourcesVectorSimilarList(params)
.then((res) => res.data),
}),
list: (params: LearningResourcesListRequest) =>then using
const { data: similarData, isLoading: similarLoading } = useQuery(
learningResourceQueries.vectorSimilar({
id: playlistId,
resource_type: [ResourceTypeEnum.VideoPlaylist],
limit: 6,
}),
)to get exactly the data you want.
There was a problem hiding this comment.
Can you please check I have changed the implementation and let me know in case i have done wrong
There was a problem hiding this comment.
-
Good: Well, these changes look great. Exactly what I was hoping for.
-
Disappointing: Thank you for making this change. Unfortunately, it seems that the OpenAPI spec is wrong, and the resource_type filter doesn't actually work. I've opened https://github.com/mitodl/hq/issues/10896 about this.
My recommendation for now: would be to leave the change as-is for the moment BUT restore a resource_type="video_playlist" filter to prevent crashing. (
const { data: similarData, isLoading: similarLoading } = useQuery({
...learningResourceQueries.vectorSimilar({
id: playlistId,
resource_type: [ResourceTypeEnum.VideoPlaylist],
limit: 6,
}),
select: (resources) =>
resources.filter(
(r) => r.resource_type === ResourceTypeEnum.VideoPlaylist,
),
})A selector like this wouldn't be bad to leave in even once the backend filtering is in place. It should be a no-op, but would also give you the correct TS type without typecasting.
Then we can coordinate on Monday about supporting resource_type filters on the similarity endpoint.
| } | ||
|
|
||
| const FeaturedVideo: React.FC<FeaturedVideoProps> = ({ videos, onPlay }) => { | ||
| const [currentIndex] = useState(0) |
There was a problem hiding this comment.
Request: similar to Zaman's comment, change the videos prop to video. We are only using one. No need to pass the whole array. Confusing for "FeaturedVideo" to take an array, IMO.
| <PageDescription> | ||
| {playlist?.description ?? | ||
| "Conversations with MIT faculty on the future of science, technology, and society."} | ||
| </PageDescription> |
There was a problem hiding this comment.
is this resolved? I still see the hard-coded description. (Maybe forgot a push?)
|
|
||
| const otherCollections = getResults(similarData).filter( | ||
| (resource): resource is VideoPlaylistResource => | ||
| resource.resource_type === ResourceTypeEnum.VideoPlaylist && |
There was a problem hiding this comment.
See https://github.com/mitodl/mit-learn/pull/3135/changes#r3053866744 ... would be better to filter at API level.
| minHeight: "100vh", | ||
| })) | ||
|
|
||
| const getResults = ( |
There was a problem hiding this comment.
The only current usage of getResults is getResults(similarData), and similarData is already a plain array, LearningResource[]. I'd suggest we remove this for now.
There was a problem hiding this comment.
I have updated the code can you please check
| const truncatedTitle = | ||
| video.title.length > FEATURED_TITLE_MAX_CHARS | ||
| ? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...` | ||
| : video.title |
There was a problem hiding this comment.
It would be better to use CSS line clamping (you do that elsewhere in this PR). We actually have a helper defined for this,
There was a problem hiding this comment.
Ferdi wants to have this title truncate on the basis of characters, the approach you are talking is based on lines that is why I have created this function to show only 30 characters.
There was a problem hiding this comment.
See
OK. We really shouldn't do it this way:
- CSS is more robust visually, see screenshot below ... awkward partially filled line.
- True truncation (not CSS based) has accessibility implications, e.g., screenreaders now get incomplete headings.
I posted something in the slack thread about it.
Note
It also seems to me that slack thread is mostly about the description not the title?
| <Section> | ||
| <VideoContainer> | ||
| <FeaturedGrid> | ||
| <ImageWrapper onClick={() => onPlay(video)}> |
There was a problem hiding this comment.
Semantic elements would be great!
| lineHeight: "16px" /* 133.333% */, | ||
| }) | ||
|
|
||
| const parseDurationToHoursAndMinutes = (duration?: string): string | null => { |
There was a problem hiding this comment.
We have a utility function formatDurationClockTime that is used to do this elsewhere.
Could we use that instead?
| const OtherCollections: React.FC<OtherCollectionsProps> = ({ | ||
| collections, | ||
| isLoading, | ||
| }) => { |
There was a problem hiding this comment.
Looking at the Figma, I'm guessing the component naming was chosen to match Figma. In theory I think that is a good idea, but it kind of just moves the burden of picking good names from us to Bilal. And naming is hard.
Looking at the two playlist pages (Series and Collection) I see they both use this component, and this component displays both types of playlists:
IMO, "RelatedPlaylist" would be a better name for this component. cc @mbilalmughal in case you want to update yours. (Doesn't need to be that name in particular, but it seems this isn't particular to collections).
cb62412 to
f851da2
Compare
zamanafzal
left a comment
There was a problem hiding this comment.
I've left some minor suggestions.
| const videoRef = useRef<HTMLDivElement>(null) | ||
| const playerRef = useRef<Player | null>(null) | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
The useEffect has unstable dependencies that could cause the video player to be recreated unnecessarily, leading to memory leaks and performance issues.
Suggestion:
useEffect(() => {
// Only initialise once
if (playerRef.current) return
const videoEl = document.createElement("video-js")
videoEl.classList.add("vjs-big-play-centered")
videoEl.style.width = "100%"
videoEl.style.height = "100%"
videoRef.current!.appendChild(videoEl)
const player = videojs(
videoEl,
{
autoplay,
controls,
fluid,
fill: !fluid,
responsive: true,
poster: poster ?? undefined,
sources,
techOrder: ["youtube", "html5"],
},
function (this: Player) {
onReady?.(this)
},
)
playerRef.current = player
}, []) // Remove all dependencies to prevent re-initialization
// Handle updates in separate effects
useEffect(() => {
const player = playerRef.current
if (!player) return
player.src(sources)
}, [sources])
useEffect(() => {
const player = playerRef.current
if (!player || !poster) return
player.poster(poster)
}, [poster])
| : null | ||
| const description = video.description ?? "" | ||
| const FEATURED_TITLE_MAX_CHARS = 30 | ||
| const truncatedTitle = |
There was a problem hiding this comment.
The title truncation is calculated on every render. How about memoized this for better performance?
const FEATURED_TITLE_MAX_CHARS = 30
const truncatedTitle = useMemo(() =>
video.title.length > FEATURED_TITLE_MAX_CHARS
? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...`
: video.title
, [video.title])
| ) | ||
| const flagsLoaded = useFeatureFlagsLoaded() | ||
|
|
||
| const { data: playlist, isLoading: playlistLoading } = useQuery( |
There was a problem hiding this comment.
I think we need to add error handling for API failures.
Suggestion:
const { data: playlist, isLoading: playlistLoading, error: playlistError } = useQuery(
videoPlaylistQueries.detail(playlistId),
)
const { data: items, isLoading: itemsLoading, error: itemsError } = useQuery(
learningResourceQueries.items(playlistId, {
learning_resource_id: playlistId,
}),
)
if (playlistError || itemsError) {
return (
<Page>
<VideoContainer>
<Typography variant="h4">Failed to load playlist</Typography>
<Typography>Please try refreshing the page.</Typography>
</VideoContainer>
</Page>
)
}
There was a problem hiding this comment.
@ChristopherChudzicki what do you think about it?
There was a problem hiding this comment.
Probably best to handle this on the nextjs server for proper 404s, etc.
https://github.com/mitodl/mit-learn/pull/3135/changes#r3015405136
e122c02 to
ad210f9
Compare
|
@ahtesham-quraish On a 1024 screen size, the content(Title, BreadCrumbs) is not properly responsive.
|
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
Changes look good.. Added a few comemnts. Two things I'd highlight especially:
- Some files seem unused...
VideoPlayerModal,videoSources,VideoJsPlayer. Let's remove these until they are actually used. (You could keep them on a different branch if you want to save them.) - See https://github.com/mitodl/mit-learn/pull/3135/changes#r3066507911 ... important comment there, unfortunately. Page is crashing right now sometimes 😦
There was a problem hiding this comment.
@ahtesham-quraish This is a video playlist collection page, right? What do you think about a url like /video-collections/:id ? Ideally we'd use a slug, but I don't think we have the data in place for that.
There was a problem hiding this comment.
@ChristopherChudzicki i dont mind to change it but i think it should be more general because we will have video series as well
There was a problem hiding this comment.
@ahtesham-quraish But those will be a different URL, right?
From the issue:
This distinction carries through to the video pages.
On a series video page... On a collection video page...
...Do not collapse this into one template just because it is easier to implement. Components can be shared. Behavior should not.
| const { data: similarData, isLoading: similarLoading } = useQuery( | ||
| learningResourceQueries.vectorSimilar(playlistId), | ||
| ) |
There was a problem hiding this comment.
-
Good: Well, these changes look great. Exactly what I was hoping for.
-
Disappointing: Thank you for making this change. Unfortunately, it seems that the OpenAPI spec is wrong, and the resource_type filter doesn't actually work. I've opened https://github.com/mitodl/hq/issues/10896 about this.
My recommendation for now: would be to leave the change as-is for the moment BUT restore a resource_type="video_playlist" filter to prevent crashing. (
const { data: similarData, isLoading: similarLoading } = useQuery({
...learningResourceQueries.vectorSimilar({
id: playlistId,
resource_type: [ResourceTypeEnum.VideoPlaylist],
limit: 6,
}),
select: (resources) =>
resources.filter(
(r) => r.resource_type === ResourceTypeEnum.VideoPlaylist,
),
})A selector like this wouldn't be bad to leave in even once the backend filtering is in place. It should be a no-op, but would also give you the correct TS type without typecasting.
Then we can coordinate on Monday about supporting resource_type filters on the similarity endpoint.
| : video?.title, | ||
| [video?.title], | ||
| ) | ||
| if (!video) return null |
There was a problem hiding this comment.
This prop is required, so no need for this early exit.
| const truncatedTitle = | ||
| video.title.length > FEATURED_TITLE_MAX_CHARS | ||
| ? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...` | ||
| : video.title |
There was a problem hiding this comment.
See
OK. We really shouldn't do it this way:
- CSS is more robust visually, see screenshot below ... awkward partially filled line.
- True truncation (not CSS based) has accessibility implications, e.g., screenreaders now get incomplete headings.
I posted something in the slack thread about it.
Note
It also seems to me that slack thread is mostly about the description not the title?
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
Changes generally look good but I'm still seeing it crash. See https://github.com/mitodl/mit-learn/pull/3135/changes#r3073950679
In addition to that, I think a collection-specific URL would be good, as would the 404 handling.
There was a problem hiding this comment.
@ahtesham-quraish But those will be a different URL, right?
From the issue:
This distinction carries through to the video pages.
On a series video page... On a collection video page...
...Do not collapse this into one template just because it is easier to implement. Components can be shared. Behavior should not.
| ) | ||
| const flagsLoaded = useFeatureFlagsLoaded() | ||
|
|
||
| const { data: playlist, isLoading: playlistLoading } = useQuery( |
There was a problem hiding this comment.
Probably best to handle this on the nextjs server for proper 404s, etc.
https://github.com/mitodl/mit-learn/pull/3135/changes#r3015405136
| isLoading: boolean | ||
| } | ||
|
|
||
| const RelatedPlaylist: React.FC<OtherCollectionsProps> = ({ |
There was a problem hiding this comment.
Should make OtherCollectionsProps match the component name.
| const otherCollections = ((similarData ?? []) as VideoPlaylistResource[]) | ||
| .filter((resource) => resource.id !== playlistId) | ||
| .slice(0, 6) |
There was a problem hiding this comment.
Currently this list can include things other than video playlists, and that causes the page to crash.
For example, using production base URL, it crashes for pretty consistently for http://open.odl.local:8062/playlist/6382
I'd suggest doing the resource_type filtering at the query call, as mentioned in https://github.com/mitodl/mit-learn/pull/3135/changes#r3066507911
That will make sure everywhere is getting playlists only.
There was a problem hiding this comment.
Suggest doing the filtering when you make the query:
const { data: similarData, isLoading: similarLoading } = useQuery({
...learningResourceQueries.vectorSimilar(playlistId),
select: (data) =>
data.filter(
(resource) => resource.resource_type === ResourceTypeEnum.VideoPlaylist,
),
})This:
- Filters on the frontend—we should filter on backend once supported
- Refines the type.
(1) will be irrelevant once we improve the backend, (2) is still useful, though
| if (isError) { | ||
| return notFound() | ||
| } |
There was a problem hiding this comment.
This can be addressed in a followup PR, but this is best to do on the server side (See copilot's suggestion, #3135 (comment)).
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
👍 one suggestion for followup PR 62d2fdf#r3074389926
62d2fdf to
7f2c916
Compare
7f2c916 to
8e0dcf3
Compare
| const getVideoHref = (resource: VideoResource) => | ||
| `/playlist/detail/${resource.id}?playlist=${playlistId}` |
There was a problem hiding this comment.
Bug: Links to video detail pages point to a non-existent /playlist/detail/[id] route, which will cause a 404 error when users click them.
Severity: HIGH
Suggested Fix
Update the link generation logic to use the correct URL pattern for video detail pages. The investigation did not identify the correct route, but it should be changed from /playlist/detail/[id] to whichever route is configured to display individual video details.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx#L33-L34
Potential issue: The code in `VideoPlaylistCollectionPage.tsx` generates links for
featured and collection videos that point to the URL pattern `/playlist/detail/[id]`.
However, the application does not have a corresponding route handler for this path.
There is no `page.tsx` file under `app/playlist/detail/[id]` and no rewrites in
`next.config.js` to handle this URL. As a result, when a user clicks on a video from the
new video playlist page, they will be directed to a non-existent page, resulting in a
404 error.





What are the relevant tickets?
https://github.com/mitodl/hq/issues/10734
Description (What does it do?)
MIT Learn already has a video playlist object, but it does not exist as a meaningful user-facing thing.
Right now playlists live as backend metadata. Users mostly land on individual videos. There is no real page for the playlist itself, and no clear way to see or navigate the grouping. Even when the content is clearly meant to belong together, it shows up as isolated videos.
Screenshots (if appropriate):
Desktop:

Mobile:

How can this be tested?
if you dont have playlist data locally then in frontend env file you should add the following variable
NEXT_PUBLIC_MITOL_API_BASE_URL="https://api.rc.learn.mit.edu"it will connect with RC learn backendthen we need to enable the flag which is
video-playlist-pageand then visit the following urlhttp://open.odl.local:8062/playlist/6831Additional Context