From 9f2971061bab80298778c74e0b6cb989ce3f8d82 Mon Sep 17 00:00:00 2001 From: Vojta Holik Date: Fri, 10 Jan 2025 11:56:50 +0100 Subject: [PATCH 1/2] feat(aih): implement next up resource retrieval & ui --- .../ai-hero/src/app/(content)/[post]/page.tsx | 79 +++++----- .../post-next-up-from-list-pagination.tsx | 39 +++++ .../lists/_components/create-list-form.tsx | 10 +- .../lists/_components/lists-table.tsx | 8 +- apps/ai-hero/src/app/(content)/lists/page.tsx | 8 +- .../posts/_components/post-player.tsx | 138 +++++++++++++++++- .../_components/post-video-subscribe-form.tsx | 2 +- .../src/components/codehike/scrollycoding.tsx | 2 +- apps/ai-hero/src/lib/lists-query.ts | 69 ++++++++- .../utils/get-nextup-resource-from-list.ts | 16 ++ 10 files changed, 313 insertions(+), 58 deletions(-) create mode 100644 apps/ai-hero/src/app/(content)/_components/post-next-up-from-list-pagination.tsx create mode 100644 apps/ai-hero/src/utils/get-nextup-resource-from-list.ts diff --git a/apps/ai-hero/src/app/(content)/[post]/page.tsx b/apps/ai-hero/src/app/(content)/[post]/page.tsx index 894bf5c02..ae26639c9 100644 --- a/apps/ai-hero/src/app/(content)/[post]/page.tsx +++ b/apps/ai-hero/src/app/(content)/[post]/page.tsx @@ -11,7 +11,10 @@ import Scrollycoding from '@/components/codehike/scrollycoding' import { PlayerContainerSkeleton } from '@/components/player-skeleton' import { PrimaryNewsletterCta } from '@/components/primary-newsletter-cta' import { Share } from '@/components/share' +import Spinner from '@/components/spinner' import { courseBuilderAdapter } from '@/db' +import type { List } from '@/lib/lists' +import { getListForPost } from '@/lib/lists-query' import { type Post } from '@/lib/posts' import { getAllPosts, getPost } from '@/lib/posts-query' import { getPricingProps } from '@/lib/pricing-query' @@ -25,7 +28,9 @@ import { compileMDX, MDXRemote } from 'next-mdx-remote/rsc' import remarkGfm from 'remark-gfm' import { Button } from '@coursebuilder/ui' +import { VideoPlayerOverlayProvider } from '@coursebuilder/ui/hooks/use-video-player-overlay' +import PostNextUpFromListPagination from '../_components/post-next-up-from-list-pagination' import { PostPlayer } from '../posts/_components/post-player' import { PostNewsletterCta } from '../posts/_components/post-video-subscribe-form' @@ -141,6 +146,7 @@ export default async function PostPage(props: { const searchParams = await props.searchParams const params = await props.params const post = await getPost(params.post) + const cookieStore = await cookies() const ckSubscriber = cookieStore.has(CK_SUBSCRIBER_KEY) const { allowPurchase, pricingDataLoader, product, commerceProps } = @@ -156,6 +162,8 @@ export default async function PostPage(props: { notFound() } + const listLoader = getListForPost(post.id) + const squareGridPattern = generateGridPattern( post.fields.title, 1000, @@ -170,11 +178,10 @@ export default async function PostPage(props: { return (
- {hasVideo && } + {hasVideo && }
- {/*
- -
-
*/} +
+ }> + +
Share
- {/* */}
@@ -262,7 +257,13 @@ export default async function PostPage(props: { ) } -async function PlayerContainer({ post }: { post: Post | null }) { +async function PlayerContainer({ + post, + listLoader, +}: { + post: Post | null + listLoader: Promise +}) { const displayOverlay = false if (!post) { @@ -276,21 +277,25 @@ async function PlayerContainer({ post }: { post: Post | null }) { const ckSubscriber = cookieStore.has(CK_SUBSCRIBER_KEY) return videoResource ? ( - - } - > -
+ + } > - - {!ckSubscriber && } -
-
+
+ + {!ckSubscriber && } +
+ + ) : resource ? null : null // spacer //
} diff --git a/apps/ai-hero/src/app/(content)/_components/post-next-up-from-list-pagination.tsx b/apps/ai-hero/src/app/(content)/_components/post-next-up-from-list-pagination.tsx new file mode 100644 index 000000000..8b933f774 --- /dev/null +++ b/apps/ai-hero/src/app/(content)/_components/post-next-up-from-list-pagination.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import Link from 'next/link' +import { type List } from '@/lib/lists' +import { getNextUpResourceFromList } from '@/utils/get-nextup-resource-from-list' +import { ArrowRight, ChevronRight } from 'lucide-react' + +import { Button } from '@coursebuilder/ui' + +export default function PostNextUpFromListPagination({ + listLoader, + postId, +}: { + listLoader: Promise + postId: string +}) { + const list = React.use(listLoader) + const nextUp = list && getNextUpResourceFromList(list, postId) + return nextUp?.resource && nextUp?.resource?.fields?.state === 'published' ? ( + + ) : null +} diff --git a/apps/ai-hero/src/app/(content)/lists/_components/create-list-form.tsx b/apps/ai-hero/src/app/(content)/lists/_components/create-list-form.tsx index 799bd0e73..483ffc6df 100644 --- a/apps/ai-hero/src/app/(content)/lists/_components/create-list-form.tsx +++ b/apps/ai-hero/src/app/(content)/lists/_components/create-list-form.tsx @@ -26,7 +26,11 @@ export function CreateListForm() { } return ( -
+ +

Create New List

@@ -48,7 +52,7 @@ export function CreateListForm() { id="description" value={description} onChange={(e) => setDescription(e.target.value)} - className="block w-full rounded-md border border-gray-300 px-3 py-2" + // className="block w-full rounded-md border border-gray-300 px-3 py-2" rows={3} />
diff --git a/apps/ai-hero/src/app/(content)/lists/_components/lists-table.tsx b/apps/ai-hero/src/app/(content)/lists/_components/lists-table.tsx index e26244680..635721383 100644 --- a/apps/ai-hero/src/app/(content)/lists/_components/lists-table.tsx +++ b/apps/ai-hero/src/app/(content)/lists/_components/lists-table.tsx @@ -29,7 +29,7 @@ export function ListsTable({ return ( <> {error && {error}} - +
Title @@ -49,12 +49,14 @@ export function ListsTable({ ? `/lists/${list.fields.slug}/edit` : `/lists/${list.fields.slug}` } - className="text-primary hover:underline" + className="text-primary text-lg font-medium hover:underline" > {list.fields.title} - {list.fields.description} + + {list.fields.description} + {list.fields.type} {list.resources?.length || 0} diff --git a/apps/ai-hero/src/app/(content)/lists/page.tsx b/apps/ai-hero/src/app/(content)/lists/page.tsx index 6fa5c808d..600792b52 100644 --- a/apps/ai-hero/src/app/(content)/lists/page.tsx +++ b/apps/ai-hero/src/app/(content)/lists/page.tsx @@ -25,9 +25,11 @@ export default async function ListsPage() { const canCreateContent = ability.can('create', 'Content') return (
-

Lists

- - {canCreateContent && } +

Lists

+
+ + {canCreateContent && } +
) } diff --git a/apps/ai-hero/src/app/(content)/posts/_components/post-player.tsx b/apps/ai-hero/src/app/(content)/posts/_components/post-player.tsx index ebe971347..5209cfd5e 100644 --- a/apps/ai-hero/src/app/(content)/posts/_components/post-player.tsx +++ b/apps/ai-hero/src/app/(content)/posts/_components/post-player.tsx @@ -2,31 +2,115 @@ import * as React from 'react' import { use } from 'react' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' import Spinner from '@/components/spinner' -import { type MuxPlayerProps } from '@mux/mux-player-react' +import { useMuxPlayer } from '@/hooks/use-mux-player' +import { + handleTextTrackChange, + setPreferredTextTrack, + useMuxPlayerPrefs, +} from '@/hooks/use-mux-player-prefs' +import type { List } from '@/lib/lists' +import { getNextUpResourceFromList } from '@/utils/get-nextup-resource-from-list' +import { + type MuxPlayerProps, + type MuxPlayerRefAttributes, +} from '@mux/mux-player-react' import MuxPlayer from '@mux/mux-player-react/lazy' +import { ArrowRight } from 'lucide-react' import { type VideoResource } from '@coursebuilder/core/schemas/video-resource' +import { Button } from '@coursebuilder/ui' +import { useVideoPlayerOverlay } from '@coursebuilder/ui/hooks/use-video-player-overlay' import { cn } from '@coursebuilder/ui/utils/cn' export function PostPlayer({ muxPlaybackId, className, videoResource, + listLoader, + postId, }: { muxPlaybackId?: string videoResource: VideoResource className?: string + listLoader: Promise + postId: string }) { + // const ability = abilityLoader ? use(abilityLoader) : null + // const canView = ability?.canView + // const playbackId = muxPlaybackId + const { dispatch: dispatchVideoPlayerOverlay, state } = + useVideoPlayerOverlay() + const { setMuxPlayerRef } = useMuxPlayer() + const playerRef = React.useRef(null) + const searchParams = useSearchParams() + const time = searchParams.get('t') + const { + playbackRate, + volume, + setPlayerPrefs, + autoplay: bingeMode, + } = useMuxPlayerPrefs() const playerProps = { - id: 'mux-player', defaultHiddenCaptions: true, streamType: 'on-demand', thumbnailTime: 0, - accentColor: '#DD9637', playbackRates: [0.75, 1, 1.25, 1.5, 1.75, 2], maxResolution: '2160p', minResolution: '540p', + accentColor: '#DD9637', + currentTime: time ? Number(time) : 0, + playbackRate, + onRateChange: (evt: Event) => { + const target = evt.target as HTMLVideoElement + const value = target.playbackRate || 1 + setPlayerPrefs({ playbackRate: value }) + }, + volume, + onVolumeChange: (evt: Event) => { + const target = evt.target as HTMLVideoElement + const value = target.volume || 1 + setPlayerPrefs({ volume: value }) + }, + onLoadedData: () => { + dispatchVideoPlayerOverlay({ type: 'HIDDEN' }) + handleTextTrackChange(playerRef, setPlayerPrefs) + setPreferredTextTrack(playerRef) + setMuxPlayerRef(playerRef) + + if (bingeMode) { + playerRef?.current?.play() + } + }, + onEnded: () => { + dispatchVideoPlayerOverlay({ type: 'COMPLETED', playerRef }) + React.startTransition(async () => { + console.log('video ended') + // await handleOnVideoEnded({ + // canView, + // resource, + // nextResource, + // nextLessonPlaybackId, + // isFullscreen, + // playerRef, + // currentResource, + // dispatchVideoPlayerOverlay, + // setCurrentResource, + // handleSetLessonComplete, + // bingeMode, + // moduleSlug, + // moduleType, + // router, + // moduleProgress, + // addLessonProgress, + // }) + }) + }, + onPlay: () => { + dispatchVideoPlayerOverlay({ type: 'HIDDEN' }) + }, } as MuxPlayerProps const playbackId = @@ -34,8 +118,11 @@ export function PostPlayer({ ? muxPlaybackId || videoResource?.muxPlaybackId : null + const list = use(listLoader) + const nextUp = list && getNextUpResourceFromList(list, postId) + return ( - <> +
{playbackId ? (
)} - + + {state.action?.type === 'COMPLETED' && nextUp && ( +
+

+ Continue +

+ + {/* {currentResourceIndexFromList} + {nextUpList && ( +
+ {nextUpList.resources.map((r) => { + return
{r.resource.fields.title}
+ })} +
+ )} */} +
+ )} + ) } + +// function getNextUpResourceFromList(list: List, currentResourceId: string) { +// if (list?.fields?.type !== 'nextUp') return null + +// const currentResourceIndexFromList = list?.resources.findIndex( +// (r) => r.resource.id === currentResourceId, +// ) +// const nextUpIndex = currentResourceIndexFromList + 1 + +// return list?.resources[nextUpIndex] +// } diff --git a/apps/ai-hero/src/app/(content)/posts/_components/post-video-subscribe-form.tsx b/apps/ai-hero/src/app/(content)/posts/_components/post-video-subscribe-form.tsx index 923fde45d..de922f5c6 100644 --- a/apps/ai-hero/src/app/(content)/posts/_components/post-video-subscribe-form.tsx +++ b/apps/ai-hero/src/app/(content)/posts/_components/post-video-subscribe-form.tsx @@ -60,7 +60,7 @@ export const PostNewsletterCta: React.FC< className="via-muted-foreground/20 absolute -bottom-px left-0 z-10 h-px w-full bg-gradient-to-r from-transparent to-transparent" aria-hidden="true" /> -
+
row.resource_id) + .map((row: any) => ({ + resource: { + id: row.resource_id, + type: row.resource_type, + fields: row.resource_fields, + }, + position: row.resource_position, + resourceId: row.resource_id, + resourceOfId: firstRow.list_id, + })) + .sort((a: any, b: any) => a.position - b.position), + } + + return ListSchema.parse(list) +} + export async function addPostToList({ postId, listId, diff --git a/apps/ai-hero/src/utils/get-nextup-resource-from-list.ts b/apps/ai-hero/src/utils/get-nextup-resource-from-list.ts new file mode 100644 index 000000000..67ebeb638 --- /dev/null +++ b/apps/ai-hero/src/utils/get-nextup-resource-from-list.ts @@ -0,0 +1,16 @@ +import { type List } from '@/lib/lists' + +export function getNextUpResourceFromList( + list: List, + currentResourceId: string, +) { + if (list?.fields?.type !== 'nextUp') return null + + const currentResourceIndexFromList = list?.resources.findIndex( + (r) => r.resource.id === currentResourceId, + ) + const nextUpIndex = currentResourceIndexFromList + 1 + const nextUpResource = list?.resources?.[nextUpIndex] + + return nextUpResource || null +} From b38d0def9668caf77b29a6f931d4649252d36b4a Mon Sep 17 00:00:00 2001 From: Vojta Holik Date: Fri, 10 Jan 2025 12:04:13 +0100 Subject: [PATCH 2/2] feat(aih): implement next up resource retrieval & ui --- .../_components/edit-post-form-metadata.tsx | 4 +- .../posts/_components/post-player.tsx | 76 ++++++++++--------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/apps/ai-hero/src/app/(content)/posts/_components/edit-post-form-metadata.tsx b/apps/ai-hero/src/app/(content)/posts/_components/edit-post-form-metadata.tsx index 89a3f07cb..563a5e073 100644 --- a/apps/ai-hero/src/app/(content)/posts/_components/edit-post-form-metadata.tsx +++ b/apps/ai-hero/src/app/(content)/posts/_components/edit-post-form-metadata.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Suspense, use } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { PostPlayer } from '@/app/(content)/posts/_components/post-player' +import { SimplePostPlayer } from '@/app/(content)/posts/_components/post-player' import Spinner from '@/components/spinner' import { env } from '@/env.mjs' import { useTranscript } from '@/hooks/use-transcript' @@ -155,7 +155,7 @@ export const PostMetadataFormFields: React.FC<{ <> {videoResource && videoResource.state === 'ready' ? (
- +
- {/* {currentResourceIndexFromList} - {nextUpList && ( -
- {nextUpList.resources.map((r) => { - return
{r.resource.fields.title}
- })} -
- )} */}
)}
) } -// function getNextUpResourceFromList(list: List, currentResourceId: string) { -// if (list?.fields?.type !== 'nextUp') return null +export function SimplePostPlayer({ + muxPlaybackId, + className, + videoResource, +}: { + muxPlaybackId?: string + videoResource: VideoResource + className?: string +}) { + const playerProps = { + id: 'mux-player', + defaultHiddenCaptions: true, + streamType: 'on-demand', + thumbnailTime: 0, + accentColor: '#DD9637', + playbackRates: [0.75, 1, 1.25, 1.5, 1.75, 2], + maxResolution: '2160p', + minResolution: '540p', + } as MuxPlayerProps -// const currentResourceIndexFromList = list?.resources.findIndex( -// (r) => r.resource.id === currentResourceId, -// ) -// const nextUpIndex = currentResourceIndexFromList + 1 + const playbackId = + videoResource?.state === 'ready' + ? muxPlaybackId || videoResource?.muxPlaybackId + : null -// return list?.resources[nextUpIndex] -// } + return ( + <> + {playbackId ? ( + + ) : ( +
+ +
+ )} + + ) +}