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}
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 && (
+
+ )}
+
+
) : 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;
}