diff --git a/prisma/migrations/20240202163217_add_completed_field_to_video_progress/migration.sql b/prisma/migrations/20240202163217_add_completed_field_to_video_progress/migration.sql new file mode 100644 index 000000000..0ceda1103 --- /dev/null +++ b/prisma/migrations/20240202163217_add_completed_field_to_video_progress/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "VideoProgress" ADD COLUMN "markAsCompleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf486f1cc..fa3303c72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,7 @@ model VideoProgress { currentTimestamp Int user User @relation(fields: [userId], references: [id], onDelete: Cascade) content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) + markAsCompleted Boolean @default(false) @@unique([contentId, userId]) } diff --git a/src/app/api/course/videoProgress/markAsCompleted/route.ts b/src/app/api/course/videoProgress/markAsCompleted/route.ts new file mode 100644 index 000000000..43ec76b94 --- /dev/null +++ b/src/app/api/course/videoProgress/markAsCompleted/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +export async function POST(req: NextRequest) { + const { contentId, markAsCompleted } = await req.json(); + const session = await getServerSession(authOptions); + if (!session || !session?.user) { + return NextResponse.json({}, { status: 401 }); + } + + const updatedRecord = await db.videoProgress.upsert({ + where: { + contentId_userId: { + contentId: Number(contentId), + userId: session.user.id, + }, + }, + create: { + contentId: Number(contentId), + userId: session.user.id, + currentTimestamp: 0, + markAsCompleted, + }, + update: { + markAsCompleted, + }, + }); + + return NextResponse.json(updatedRecord); +} diff --git a/src/app/api/course/videoProgress/route.ts b/src/app/api/course/videoProgress/route.ts index 59c5001f6..aa2b643b9 100644 --- a/src/app/api/course/videoProgress/route.ts +++ b/src/app/api/course/videoProgress/route.ts @@ -21,15 +21,31 @@ export async function GET(req: NextRequest) { }); return NextResponse.json({ progress: currentProgress?.currentTimestamp ?? 0, + markAsCompleted: currentProgress?.markAsCompleted ?? false, }); } -export async function POST() { +export async function POST(req: NextRequest) { + const { contentId, currentTimestamp } = await req.json(); const session = await getServerSession(authOptions); - if (!session || !session?.user) { return NextResponse.json({}, { status: 401 }); } - - return NextResponse.json({}); + const updatedRecord = await db.videoProgress.upsert({ + where: { + contentId_userId: { + contentId: Number(contentId), + userId: session.user.id, + }, + }, + create: { + contentId: Number(contentId), + userId: session.user.id, + currentTimestamp, + }, + update: { + currentTimestamp, + }, + }); + return NextResponse.json(updatedRecord); } diff --git a/src/components/ContentCard.tsx b/src/components/ContentCard.tsx index ba36abaf6..4da8c2962 100644 --- a/src/components/ContentCard.tsx +++ b/src/components/ContentCard.tsx @@ -1,14 +1,33 @@ +import { CheckCircle2 } from 'lucide-react'; +import PercentageComplete from './PercentageComplete'; + export const ContentCard = ({ image, title, onClick, + markAsCompleted, + percentComplete, }: { + contentId?: number image: string title: string onClick: () => void + markAsCompleted?: boolean + percentComplete?: number | null }) => { return ( -
+
+ {percentComplete !== null && percentComplete !== undefined && ( + + )} + {markAsCompleted && ( +
+ +
+ )} {title}
{title}
diff --git a/src/components/CourseCard.tsx b/src/components/CourseCard.tsx index 01cc2ae42..f35a97a15 100644 --- a/src/components/CourseCard.tsx +++ b/src/components/CourseCard.tsx @@ -1,5 +1,6 @@ 'use client'; import { Course } from '@/store/atoms'; +import PercentageComplete from './PercentageComplete'; import { Button } from './ui/button'; import { ChevronRight } from 'lucide-react'; @@ -17,6 +18,17 @@ export const CourseCard = ({ onClick(); }} > +
+ {course.totalVideos !== undefined && + course.totalVideosWatched !== undefined && ( + + )} + {course.title} +
) : null} @@ -47,6 +51,9 @@ export const CourseView = ({ title: x?.title || '', image: x?.thumbnail || '', id: x?.id || 0, + markAsCompleted: + x?.videoProgress[0]?.markAsCompleted || false, + percentComplete: getFolderPercentCompleted(x?.children), }))} courseId={parseInt(course.id, 10)} /> diff --git a/src/components/FolderView.tsx b/src/components/FolderView.tsx index 0795c517f..bb418e4e5 100644 --- a/src/components/FolderView.tsx +++ b/src/components/FolderView.tsx @@ -13,6 +13,8 @@ export const FolderView = ({ title: string image: string id: number + markAsCompleted: boolean + percentComplete: number | null }[] }) => { const router = useRouter(); @@ -34,18 +36,18 @@ export const FolderView = ({
- {courseContent.map( - (content: { image: string; id: number; title: string }) => ( - { - router.push(`${updatedRoute}/${content.id}`); - }} - /> - ), - )} + {courseContent.map((content) => ( + { + router.push(`${updatedRoute}/${content.id}`); + }} + markAsCompleted={content.markAsCompleted} + percentComplete={content.percentComplete} + /> + ))}
); diff --git a/src/components/PercentageComplete.tsx b/src/components/PercentageComplete.tsx new file mode 100644 index 000000000..30acf4f9d --- /dev/null +++ b/src/components/PercentageComplete.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const PercentageComplete = ({ percent }: { percent: number }) => { + return ( +
+
{`${percent}% completed`}
+
+ ); +}; + +export default PercentageComplete; diff --git a/src/components/VideoContentChapters.tsx b/src/components/VideoContentChapters.tsx index 425c3a1fa..7430eef27 100644 --- a/src/components/VideoContentChapters.tsx +++ b/src/components/VideoContentChapters.tsx @@ -40,7 +40,7 @@ const VideoContentChapters = ({
- {(segments as Segment[]).map(({ start, end, title }, index) => { + {(segments as Segment[])?.map(({ start, end, title }, index) => { return ( <>
void subtitles?: string contentId: number + onVideoEnd: () => void } const PLAYBACK_RATES: number[] = [0.5, 1, 1.25, 1.5, 1.75, 2]; @@ -24,6 +26,7 @@ export const VideoPlayer: FunctionComponent = ({ contentId, onReady, subtitles, + onVideoEnd, }) => { const videoRef = useRef(null); const playerRef = useRef(null); @@ -140,23 +143,46 @@ export const VideoPlayer: FunctionComponent = ({ }, [player]); useEffect(() => { - const interval = window.setInterval(async () => { - const currentTime = player.currentTime(); - if (currentTime <= 20) { + if (!player) { + return; + } + let interval = 0; + + const handleVideoProgress = () => { + if (!player) { return; } - await fetch('/api/course/videoProgress', { - body: JSON.stringify({ - currentTimestamp: currentTime, - contentId, - }), - method: 'POST', - headers: { - 'Content-Type': 'application/json', + interval = window.setInterval( + async () => { + if (player?.paused()) { + return; + } + const currentTime = player.currentTime(); + if (currentTime <= 20) { + return; + } + await fetch('/api/course/videoProgress', { + body: JSON.stringify({ + currentTimestamp: currentTime, + contentId, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); }, - }); - }, 10 * 1000); + Math.ceil((10 * 1000) / player.playbackRate()), + ); + }; + const handleVideoEnded = (interval: number) => { + handleMarkAsCompleted(true, contentId); + window.clearInterval(interval); + onVideoEnd(); + }; + player.on('play', handleVideoProgress); + player.on('ended', () => handleVideoEnded(interval)); return () => { window.clearInterval(interval); }; diff --git a/src/components/VideoPlayerSegment.tsx b/src/components/VideoPlayerSegment.tsx index 22530787b..a545a5b7e 100644 --- a/src/components/VideoPlayerSegment.tsx +++ b/src/components/VideoPlayerSegment.tsx @@ -23,6 +23,7 @@ interface VideoProps { subtitles: string videoJsOptions: any contentId: number + onVideoEnd: () => void } export const VideoPlayerSegment: FunctionComponent = ({ @@ -30,6 +31,7 @@ export const VideoPlayerSegment: FunctionComponent = ({ subtitles, segments, videoJsOptions, + onVideoEnd, }) => { const playerRef = useRef(null); const thumbnailPreviewRef = useRef(null); @@ -95,6 +97,7 @@ export const VideoPlayerSegment: FunctionComponent = ({ subtitles={subtitles} options={videoJsOptions} onReady={handlePlayerReady} + onVideoEnd={onVideoEnd} />
diff --git a/src/components/admin/ContentRenderer.tsx b/src/components/admin/ContentRenderer.tsx index 62a990b2d..9f47a0ee7 100644 --- a/src/components/admin/ContentRenderer.tsx +++ b/src/components/admin/ContentRenderer.tsx @@ -75,6 +75,7 @@ export const ContentRenderer = async ({ description: string thumbnail: string slides?: string + markAsCompleted: boolean } }) => { const metadata = await getMetadata(content.id); diff --git a/src/components/admin/ContentRendererClient.tsx b/src/components/admin/ContentRendererClient.tsx index 3226fa8d2..6125a4de5 100644 --- a/src/components/admin/ContentRendererClient.tsx +++ b/src/components/admin/ContentRendererClient.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useSearchParams } from 'next/navigation'; +import { useSearchParams, useRouter } from 'next/navigation'; import { QualitySelector } from '../QualitySelector'; import { VideoPlayerSegment } from '@/components/VideoPlayerSegment'; -import { useRouter } from 'next/navigation'; import VideoContentChapters from '../VideoContentChapters'; import { useState } from 'react'; +import { handleMarkAsCompleted } from '@/lib/utils'; export const ContentRendererClient = ({ metadata, @@ -23,8 +23,13 @@ export const ContentRendererClient = ({ title: string thumbnail: string description: string + markAsCompleted: boolean } }) => { + const [contentCompleted, setContentCompleted] = useState( + content.markAsCompleted, + ); + const [loadingMarkAs, setLoadingMarkAs] = useState(false); const [showChapters, setShowChapters] = useState( metadata?.segments?.length > 0, ); @@ -70,9 +75,19 @@ export const ContentRendererClient = ({ setShowChapters((prev) => !prev); }; + const handleMarkCompleted = async () => { + setLoadingMarkAs(true); + const data: any = await handleMarkAsCompleted(!contentCompleted, content.id); + + if (data.contentId) { + setContentCompleted((prev) => !prev); + } + setLoadingMarkAs(false); + }; + return (
-
+
{ + setContentCompleted(true); + }} />
-
- {content.title} +
+
+ {content.title} +
+ +
+

@@ -120,7 +149,7 @@ export const ContentRendererClient = ({
) : null} - {!showChapters && ( + {!showChapters && metadata.segments?.length > 0 && (
diff --git a/src/db/course.ts b/src/db/course.ts index 831e340d5..074d30974 100644 --- a/src/db/course.ts +++ b/src/db/course.ts @@ -81,7 +81,11 @@ async function getAllContent() { if (value) { return value; } - const allContent = await db.content.findMany({}); + const allContent = await db.content.findMany({ + include: { + videoProgress: true, + }, + }); Cache.getInstance().set('getAllContent', [], allContent); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 93ecccc9c..feb0c930f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -143,3 +143,34 @@ export const getCurrentSegmentName = ( ); return currentSegment ? currentSegment.title : ''; }; + +export const handleMarkAsCompleted = async ( + markAsCompleted: boolean, + contentId: number, +) => { + const response = await fetch('/api/course/videoProgress/markAsCompleted', { + body: JSON.stringify({ + markAsCompleted, + contentId, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + return await response.json(); +}; + +export const getFolderPercentCompleted = (childrenContent: any) => { + if (childrenContent && childrenContent.length > 0) { + const videos = childrenContent.filter( + (content: any) => content.type === 'video', + ); + const totalVideosWatched = videos.filter( + ({ videoProgress }: any) => + videoProgress && videoProgress[0]?.markAsCompleted, + ).length; + return Math.ceil((totalVideosWatched / videos.length) * 100); + } + return null; +}; diff --git a/src/store/atoms/courses.ts b/src/store/atoms/courses.ts index 4ff1b584e..f0ba17431 100644 --- a/src/store/atoms/courses.ts +++ b/src/store/atoms/courses.ts @@ -10,6 +10,8 @@ export type Course = { appxCourseId: number discordRoleId: string openToEveryone: boolean + totalVideos?: number + totalVideosWatched?: number } const coursesSelector = selector({ diff --git a/src/utiles/appx.ts b/src/utiles/appx.ts index 9b8e71fc2..45c2c51d8 100644 --- a/src/utiles/appx.ts +++ b/src/utiles/appx.ts @@ -7,7 +7,43 @@ const APPX_BASE_API = process.env.APPX_BASE_API; const LOCAL_CMS_PROVIDER = process.env.LOCAL_CMS_PROVIDER; export async function getPurchases(email: string) { - const courses = await db.course.findMany({}); + const _courses = await db.course.findMany({ + include: { + content: { + select: { + content: { + select: { + children: { + where: { + type: 'video', + }, + select: { + videoProgress: true, + }, + }, + }, + }, + }, + }, + }, + }); + const courses = _courses.map((course) => { + const { content } = course; + let totalVideos = 0; + let totalVideosWatched = 0; + content.forEach(({ content: { children } }) => { + totalVideos += children.length; + totalVideosWatched += children.filter( + ({ videoProgress }) => + videoProgress && videoProgress[0]?.markAsCompleted, + ).length; + }); + return { + ...course, + ...(content.length > 0 && { totalVideos, totalVideosWatched }), + }; + }); + if (LOCAL_CMS_PROVIDER) { return courses; }