From f0d93bc32de126269cc07369c0c622ed9983d7ca Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Fri, 31 Jan 2025 16:46:42 -0700 Subject: [PATCH] feat: Post completion overlay (#1505) * chore: remove console logs * fix: remove typesense indexing Not sure why we index on the front end but this was removing tags from indexed posts and generally out of sync with builder * feat: add completion overlay to posts - add CTA to Cursor workshop for posts tagged with cursor - pull tags from course builder * chore: remove console.log * design: stack CTA buttons on desktop * Update src/pages/[post].tsx * fix: pass tags correctly * fix: update CTA heading --- src/components/posts/video-player-overlay.tsx | 144 +++++++++++++++++ src/hooks/mux/use-video-player-overlay.tsx | 95 +++++++++++ src/hooks/use-mux-player.tsx | 36 +++++ src/pages/[post].tsx | 150 ++++++++---------- 4 files changed, 344 insertions(+), 81 deletions(-) create mode 100644 src/components/posts/video-player-overlay.tsx create mode 100644 src/hooks/mux/use-video-player-overlay.tsx create mode 100644 src/hooks/use-mux-player.tsx diff --git a/src/components/posts/video-player-overlay.tsx b/src/components/posts/video-player-overlay.tsx new file mode 100644 index 000000000..9be461e83 --- /dev/null +++ b/src/components/posts/video-player-overlay.tsx @@ -0,0 +1,144 @@ +import Link from 'next/link' +import Image from 'next/image' + +import { + useVideoPlayerOverlay, + type CompletedAction, +} from '@/hooks/mux/use-video-player-overlay' +import {Post} from '@/pages/[post]' +import Spinner from '@/spinner' +import {Button} from '@/ui/button' +import {ArrowRight} from 'lucide-react' +import {RefreshCw} from 'lucide-react' +import {Card, CardContent, CardTitle, CardHeader, CardFooter} from '@/ui/card' +import {motion} from 'framer-motion' + +type VideoPlayerOverlayProps = { + resource: Post +} + +const VideoPlayerOverlay: React.FC = ({resource}) => { + const {state: overlayState, dispatch} = useVideoPlayerOverlay() + + switch (overlayState.action?.type) { + case 'COMPLETED': + const {playerRef, cta} = overlayState.action as CompletedAction + + if (cta === 'cursor_workshop') { + return ( + + { + if (playerRef.current) { + playerRef.current.play() + } + dispatch({type: 'HIDDEN'}) + }} + /> + + ) + } + + return ( + +
+

+ {resource.fields.title} +

+ +
+
+ ) + case 'LOADING': + return ( +
+ +
+ ) + case 'HIDDEN': + return null + default: + return null + } +} + +export default VideoPlayerOverlay + +function CursorCTAOverlay({ + signUpLink, + onReplay, +}: { + signUpLink: string + onReplay: () => void +}) { + return ( +
+ + +
+
+ Cursor +

+ Take Cursor to the Next Level with Live Training +

+
+

+ John Lindquist is teaching a workshop on how to use Cursor to it's + fullest abilities. Get notified when the workshop is released. +

+
+
+ + + + + + +
+
+ ) +} diff --git a/src/hooks/mux/use-video-player-overlay.tsx b/src/hooks/mux/use-video-player-overlay.tsx new file mode 100644 index 000000000..e5bd78022 --- /dev/null +++ b/src/hooks/mux/use-video-player-overlay.tsx @@ -0,0 +1,95 @@ +'use client' + +import React, {createContext, Reducer, useContext, useReducer} from 'react' +import type {MuxPlayerRefAttributes} from '@mux/mux-player-react' + +type VideoPlayerOverlayState = { + action: VideoPlayerOverlayAction | null +} + +export type CompletedAction = { + type: 'COMPLETED' + playerRef: React.RefObject + cta?: string +} + +export type VideoPlayerOverlayAction = + | CompletedAction + | {type: 'BLOCKED'} + | {type: 'HIDDEN'} + | {type: 'LOADING'} + +const initialState: VideoPlayerOverlayState = { + action: { + type: 'HIDDEN', + }, +} + +const reducer: Reducer = ( + state, + action, +) => { + switch (action.type) { + case 'COMPLETED': + // TODO: Track video completion + return { + ...state, + action, + } + case 'LOADING': + console.log('loading') + return { + ...state, + action, + } + case 'BLOCKED': + return { + ...state, + action, + } + case 'HIDDEN': { + return { + ...state, + action, + } + } + default: + return state + } +} + +type VideoPlayerOverlayContextType = { + state: VideoPlayerOverlayState + dispatch: React.Dispatch +} + +export const VideoPlayerOverlayProvider: React.FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(reducer, initialState) + + const value = React.useMemo(() => ({state, dispatch}), [state, dispatch]) + + return ( + + {children} + + ) +} + +const VideoPlayerOverlayContext = createContext< + VideoPlayerOverlayContextType | undefined +>(undefined) + +export const useVideoPlayerOverlay = () => { + const context = useContext(VideoPlayerOverlayContext) + if (!context) { + throw new Error( + 'useVideoPlayerContext must be used within a VideoPlayerProvider', + ) + } + return { + state: context.state, + dispatch: context.dispatch, + } +} diff --git a/src/hooks/use-mux-player.tsx b/src/hooks/use-mux-player.tsx new file mode 100644 index 000000000..a26c5a285 --- /dev/null +++ b/src/hooks/use-mux-player.tsx @@ -0,0 +1,36 @@ +'use client' + +import React, {createContext, useContext} from 'react' +import type {MuxPlayerRefAttributes} from '@mux/mux-player-react' + +type MuxPlayerContextType = { + setMuxPlayerRef: React.Dispatch< + React.SetStateAction | null> + > + muxPlayerRef: React.RefObject | null +} + +export const MuxPlayerProvider: React.FC = ({ + children, +}) => { + const [muxPlayerRef, setMuxPlayerRef] = + React.useState | null>(null) + + return ( + + {children} + + ) +} + +const MuxPlayerContext = createContext( + undefined, +) + +export const useMuxPlayer = () => { + const context = useContext(MuxPlayerContext) + if (!context) { + throw new Error('useMuxPlayer must be used within a MuxPlayerProvider') + } + return context +} diff --git a/src/pages/[post].tsx b/src/pages/[post].tsx index 239d684cd..a217d8d03 100644 --- a/src/pages/[post].tsx +++ b/src/pages/[post].tsx @@ -1,5 +1,8 @@ import {GetServerSideProps, GetStaticPaths} from 'next' -import MuxPlayer, {MuxPlayerProps} from '@mux/mux-player-react' +import MuxPlayer, { + type MuxPlayerProps, + type MuxPlayerRefAttributes, +} from '@mux/mux-player-react' import * as mysql from 'mysql2/promise' import {ConnectionOptions, RowDataPacket} from 'mysql2/promise' import {NextSeo} from 'next-seo' @@ -28,6 +31,12 @@ import {LikeButton} from '@/components/like-button' import BlueskyLink from '@/components/share-bluesky' import {z} from 'zod' import GoProCtaOverlay from '@/components/pages/lessons/overlay/go-pro-cta-overlay' +import { + VideoPlayerOverlayProvider, + useVideoPlayerOverlay, +} from '@/hooks/mux/use-video-player-overlay' +import VideoPlayerOverlay from '@/components/posts/video-player-overlay' +import {MuxPlayerProvider, useMuxPlayer} from '@/hooks/use-mux-player' export const FieldsSchema = z.object({ body: z.string().optional(), @@ -185,9 +194,7 @@ type MuxTimeUpdateEvent = { } async function getPost(slug: string) { - console.log('Getting post for slug:', slug) const {hashFromSlug, originalSlug} = parseSlugForHash(slug) - console.log('Parsed slug:', {hashFromSlug, originalSlug}) const conn = await mysql.createConnection(access) @@ -205,7 +212,6 @@ async function getPost(slug: string) { `, [slug, slug, `%${hashFromSlug}`, `%${hashFromSlug}`], ) - console.log('Video resource rows:', videoResourceRows) // Get post data with proper type checking const [postRows] = await conn.execute( @@ -234,9 +240,27 @@ async function getPost(slug: string) { throw new Error(`Invalid post data: ${postData.error.message}`) } + // Get tags for a post + const [tagRows] = await conn.execute( + ` + SELECT + egh_tag.id, + JSON_UNQUOTE(JSON_EXTRACT(egh_tag.fields, '$.name')) AS name, + JSON_UNQUOTE(JSON_EXTRACT(egh_tag.fields, '$.slug')) AS slug, + JSON_UNQUOTE(JSON_EXTRACT(egh_tag.fields, '$.label')) AS label, + JSON_UNQUOTE(JSON_EXTRACT(egh_tag.fields, '$.image_url')) AS image_url + FROM egghead_ContentResourceTag crt + LEFT JOIN egghead_Tag egh_tag ON crt.tagId = egh_tag.id + WHERE crt.contentResourceId = ? + `, + [postData.data.id], + ) + const tags = tagRows + return { videoResource, post: postData.data, + tags, } } catch (error) { console.error('Error in getPost:', error) @@ -261,60 +285,11 @@ export const getStaticProps: GetServerSideProps = async function ({params}) { } } - const {post, videoResource} = result + const {post, videoResource, tags} = result const lesson = await fetch( `${process.env.NEXT_PUBLIC_AUTH_DOMAIN}/api/v1/lessons/${post.fields.eggheadLessonId}`, ).then((res) => res.json()) - const resource = { - id: `${post.fields.eggheadLessonId}`, - externalId: post.id, - title: post.fields.title, - slug: post.fields.slug, - summary: post.fields.description, - description: post.fields.body, - name: post.fields.title, - path: `/${post.fields.slug}`, - type: post.fields.postType, - ...(lesson && { - instructor_name: lesson.instructor?.full_name, - instructor: lesson.instructor, - image: lesson.image_480_url, - }), - } - - let client = new Typesense.Client({ - nodes: [ - { - host: process.env.NEXT_PUBLIC_TYPESENSE_HOST!, - port: 443, - protocol: 'https', - }, - ], - apiKey: process.env.TYPESENSE_WRITE_API_KEY!, - connectionTimeoutSeconds: 2, - }) - - if ( - post.fields.state === 'published' && - post.fields.visibility === 'public' - ) { - await client - .collections(process.env.TYPESENSE_COLLECTION_NAME!) - .documents() - .upsert({ - ...resource, - published_at_timestamp: post.updatedAt.getTime(), - }) - .catch((err) => {}) - } else { - await client - .collections(process.env.TYPESENSE_COLLECTION_NAME!) - .documents() - .delete(resource.id) - .catch((err) => {}) - } - const mdxSource = await serializeMDX(post.fields?.body ?? '', { useShikiTwoslash: true, syntaxHighlighterOptions: { @@ -323,25 +298,6 @@ export const getStaticProps: GetServerSideProps = async function ({params}) { }, }) - // const mdxSource = await serialize(post.fields.body, { - // mdxOptions: { - // remarkPlugins: [ - // require(`remark-slug`), - // require(`remark-footnotes`), - // require(`remark-code-titles`), - // ], - // rehypePlugins: [ - // [ - // require(`rehype-shiki`), - // { - // theme: `./src/styles/material-theme-dark.json`, - // useBackground: false, - // }, - // ], - // ], - // }, - // }) - return { props: { mdxSource, @@ -354,7 +310,7 @@ export const getStaticProps: GetServerSideProps = async function ({params}) { }), }, videoResource: convertToSerializeForNextResponse(videoResource), - tags: lesson?.tags || [], + tags: tags || [], }, revalidate: 60, } @@ -428,13 +384,25 @@ export default function PostPage({ ], }} /> + {videoResource && ( - +
+ + +
+ + +
+
+
+
)} +
{post.fields.state === 'draft' && (
@@ -563,11 +531,13 @@ function PostPlayer({ eggheadLessonId, playerProps = defaultPlayerProps, post, + postTags, }: { playbackId: string eggheadLessonId?: number | null playerProps?: MuxPlayerProps post: Post + postTags: Tag[] }) { const [writingProgress, setWritingProgress] = React.useState(false) const {mutate: markLessonComplete} = @@ -579,6 +549,10 @@ function PostPlayer({ const {data: viewer} = trpc.user.current.useQuery() const isPro = post.fields.access === 'pro' + const {setMuxPlayerRef} = useMuxPlayer() + const playerRef = React.useRef(null) + const {dispatch: dispatchVideoPlayerOverlay} = useVideoPlayerOverlay() + const canView = !isPro || (isPro && Boolean(viewer) && Boolean(viewer?.is_pro)) @@ -609,8 +583,22 @@ function PostPlayer({ video_category: post.fields.primaryTagId, }} playbackId={playbackId} + ref={playerRef} + onLoadedData={() => { + dispatchVideoPlayerOverlay({type: 'HIDDEN'}) + setMuxPlayerRef(playerRef) + }} onEnded={() => { if (eggheadLessonId) { + if (postTags.some((tag) => tag.name === 'cursor')) { + dispatchVideoPlayerOverlay({ + type: 'COMPLETED', + playerRef, + cta: 'cursor_workshop', + }) + } else { + dispatchVideoPlayerOverlay({type: 'COMPLETED', playerRef}) + } markLessonComplete({ lessonId: eggheadLessonId, }) @@ -735,7 +723,7 @@ const TagList = ({ }} className="inline-flex items-center hover:underline" > - {tag.image_url && ( + {tag?.image_url && tag?.image_url !== 'null' && ( {tag.name} { aria-label="Video Powered by Mux" fill="none" > - +