Skip to content

Commit

Permalink
feat: users can now see their watch history
Browse files Browse the repository at this point in the history
  • Loading branch information
nimit9 committed Mar 20, 2024
1 parent 8125ae2 commit d14337b
Show file tree
Hide file tree
Showing 15 changed files with 514 additions and 23 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dayjs": "^1.11.10",
"discord-oauth2": "^2.11.0",
"discord.js": "^14.14.1",
"embla-carousel-react": "^8.0.0",
"jose": "^5.2.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.321.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "VideoProgress" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "videoDuration" INTEGER NOT NULL DEFAULT 0;
13 changes: 7 additions & 6 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ model User {
disableDrm Boolean @default(false)
password String?
appxUserId String?
appxUsername String?
appxUsername String?
}

model DiscordConnect {
Expand All @@ -147,13 +147,15 @@ model DiscordConnectBulk {
}

model VideoProgress {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
userId String
contentId Int
currentTimestamp Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
content Content @relation(fields: [contentId], references: [id], onDelete: Cascade)
markAsCompleted Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
content Content @relation(fields: [contentId], references: [id], onDelete: Cascade)
markAsCompleted Boolean @default(false)
updatedAt DateTime @default(now()) @updatedAt
videoDuration Int @default(0)
@@unique([contentId, userId])
}
Expand Down Expand Up @@ -200,4 +202,3 @@ enum CommentType {
INTRO
DEFAULT
}

6 changes: 5 additions & 1 deletion src/app/api/course/videoProgress/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import db from '@/db';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { revalidatePath } from 'next/cache';

export async function GET(req: NextRequest) {
const url = new URL(req.url);
Expand All @@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
}

export async function POST(req: NextRequest) {
const { contentId, currentTimestamp } = await req.json();
const { contentId, currentTimestamp, videoDuration } = await req.json();
const session = await getServerSession(authOptions);
if (!session || !session?.user) {
return NextResponse.json({}, { status: 401 });
Expand All @@ -42,10 +43,13 @@ export async function POST(req: NextRequest) {
contentId: Number(contentId),
userId: session.user.id,
currentTimestamp,
videoDuration,
},
update: {
currentTimestamp,
...(videoDuration && { videoDuration }),
},
});
revalidatePath('/history');
return NextResponse.json(updatedRecord);
}
11 changes: 11 additions & 0 deletions src/app/history/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CourseSkeleton } from '@/components/CourseCard';

export default function Loading() {
return (
<div className="max-w-screen-xl justify-between mx-auto p-4 cursor-pointer grid grid-cols-1 gap-5 md:grid-cols-3">
{[1, 2, 3].map((v) => (
<CourseSkeleton key={v} />
))}
</div>
);
}
90 changes: 90 additions & 0 deletions src/app/history/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import db from '@/db';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { Content, CourseContent, VideoProgress } from '@prisma/client';
import WatchHistoryClient from '@/components/WatchHistoryClient';
import { Fragment } from 'react';

export type TWatchHistory = VideoProgress & {
content: Content & {
parent: { id: number; courses: CourseContent[] } | null;
};
};

const formatWatchHistoryDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const diffInDays = diff / (1000 * 60 * 60 * 24);

if (diffInDays < 1) {
return 'Today';
} else if (diffInDays < 2) {
return 'Yesterday';
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};

const groupByWatchedDate = (userVideoProgress: TWatchHistory[]) => {
return userVideoProgress.reduce(
(acc, item) => {
const date = new Date(item.updatedAt);
const formattedDate = formatWatchHistoryDate(date);

if (!acc[formattedDate]) {
acc[formattedDate] = [];
}
acc[formattedDate].push(item);
return acc;
},
{} as { [key: string]: TWatchHistory[] },
);
};

async function getWatchHistory() {
const session = await getServerSession(authOptions);
const userId = session.user.id;

const userVideoProgress: TWatchHistory[] = await db.videoProgress.findMany({
where: {
userId,
},
include: {
content: {
include: {
parent: {
select: {
id: true,
courses: true,
},
},
},
},
},
orderBy: {
updatedAt: 'desc',
},
});

return userVideoProgress;
}

export default async function CoursesComponent() {
const watchHistory = await getWatchHistory();
const watchHistoryGroupedByDate = groupByWatchedDate(watchHistory);

return (
<div className="px-4 md:px-20 py-5">
<h1 className="text-2xl font-bold">Watch history</h1>
<div className="flex flex-col gap-4 my-4 sm:my-8 max-w-full">
{Object.entries(watchHistoryGroupedByDate).map(([date, history]) => {
return (
<Fragment key={date}>
<h2 className="text-lg font-semibold mt-4 sm:mt-0">{date}</h2>
<WatchHistoryClient history={history} />
</Fragment>
);
})}
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion src/components/Appbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export const Appbar = () => {
Assignments
</Link>
</Button>

<Button size={'sm'} variant={'link'} asChild>
<Link href={'/history'}>Watch History</Link>
</Button>
<AppbarAuth />
</div>

Expand Down
18 changes: 16 additions & 2 deletions src/components/ContentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const ContentCard = ({
markAsCompleted,
percentComplete,
type,
videoProgressPercent,
hoverExpand = true,
}: {
type: 'folder' | 'video' | 'notion';
contentId?: number;
Expand All @@ -15,6 +17,8 @@ export const ContentCard = ({
onClick: () => void;
markAsCompleted?: boolean;
percentComplete?: number | null;
videoProgressPercent?: number;
hoverExpand?: boolean;
}) => {
let image =
'https://d2szwvl7yo497w.cloudfront.net/courseThumbnails/folder.png';
Expand All @@ -26,7 +30,7 @@ export const ContentCard = ({
return (
<div
onClick={onClick}
className="relative hover:scale-105 ease-in duration-200"
className={`relative ease-in duration-200 cursor-pointer group${hoverExpand ? ' hover:scale-105' : ''} `}
>
{percentComplete !== null && percentComplete !== undefined && (
<PercentageComplete percent={percentComplete} />
Expand All @@ -36,7 +40,17 @@ export const ContentCard = ({
<CheckCircle2 color="green" size={20} />
</div>
)}
<img src={image} alt={title} className="rounded-md" />
<div className="relative overflow-hidden rounded-md">
<img src={image} alt={title} className="" />
{!!videoProgressPercent && (
<div className="absolute bottom-0 w-full h-1 bg-[#707071]">
<div
className="h-full bg-[#FF0101]"
style={{ width: `${videoProgressPercent}%` }}
/>
</div>
)}
</div>
<div className="flex justify-between mt-2 text-gray-900 dark:text-white">
<div>{title}</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/CourseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const CourseView = ({
id: x?.id || 0,
markAsCompleted: x?.videoProgress?.markAsCompleted || false,
percentComplete: getFolderPercentCompleted(x?.children),
videoFullDuration: x?.videoProgress?.videoFullDuration || 0,
duration: x?.videoProgress?.duration || 0,
}))}
courseId={parseInt(course.id, 10)}
/>
Expand Down
37 changes: 24 additions & 13 deletions src/components/FolderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const FolderView = ({
id: number;
markAsCompleted: boolean;
percentComplete: number | null;
videoFullDuration?: number;
duration?: number;
}[];
}) => {
const router = useRouter();
Expand All @@ -37,19 +39,28 @@ export const FolderView = ({
<div>
<div></div>
<div className="max-w-screen-xl justify-between mx-auto p-4 cursor-pointer grid grid-cols-1 gap-5 md:grid-cols-3">
{courseContent.map((content) => (
<ContentCard
type={content.type}
key={content.id}
title={content.title}
image={content.image || ''}
onClick={() => {
router.push(`${updatedRoute}/${content.id}`);
}}
markAsCompleted={content.markAsCompleted}
percentComplete={content.percentComplete}
/>
))}
{courseContent.map((content) => {
const videoProgressPercent =
content.type === 'video' &&
content.videoFullDuration &&
content.duration
? (content.duration / content.videoFullDuration) * 100
: 0;
return (
<ContentCard
type={content.type}
key={content.id}
title={content.title}
image={content.image || ''}
onClick={() => {
router.push(`${updatedRoute}/${content.id}`);
}}
markAsCompleted={content.markAsCompleted}
percentComplete={content.percentComplete}
videoProgressPercent={videoProgressPercent}
/>
);
})}
</div>
</div>
);
Expand Down
20 changes: 20 additions & 0 deletions src/components/VideoPlayer2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const VideoPlayer: FunctionComponent<VideoPlayerProps> = ({
}) => {
const videoRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Player | null>(null);
const [videoDuration, setVideoDuration] = useState<number | null>(null);
const [player, setPlayer] = useState<any>(null);
const searchParams = useSearchParams();
useEffect(() => {
Expand Down Expand Up @@ -179,13 +180,32 @@ export const VideoPlayer: FunctionComponent<VideoPlayerProps> = ({
}
interval = window.setInterval(
async () => {
if (!player) {
return;
}
if (player?.paused()) {
return;
}
const currentTime = player.currentTime();
if (currentTime <= 20) {
return;
}
const duration = Math.floor(player.duration());
if (!videoDuration) {
await fetch('/api/course/videoProgress', {
body: JSON.stringify({
currentTimestamp: currentTime,
contentId,
videoDuration: duration,
}),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
setVideoDuration(duration);
return;
}
await fetch('/api/course/videoProgress', {
body: JSON.stringify({
currentTimestamp: currentTime,
Expand Down
61 changes: 61 additions & 0 deletions src/components/WatchHistoryClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import { ContentCard } from '@/components/ContentCard';
import { TWatchHistory } from '../app/history/page';
import { useRouter } from 'next/navigation';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
} from '@/components/ui/carousel';

const WatchHistoryClient = ({ history }: { history: TWatchHistory[] }) => (
<Carousel>
<CarouselContent>
{history.map((progress) => (
<CarouselItem
className="basis-1/2 md:basis-1/3 lg:basis-1/5"
key={progress.id}
>
<HistoryCard {...progress} />
</CarouselItem>
))}
</CarouselContent>
<CarouselNext />
</Carousel>
);

const HistoryCard = ({
id,
contentId,
currentTimestamp,
videoDuration,
content: { type, title, thumbnail, hidden, parent },
}: TWatchHistory) => {
const router = useRouter();

if (parent && !hidden && type === 'video') {
const { id: folderId, courses } = parent;
const courseId = courses[0].courseId;
const videoUrl = `/courses/${courseId}/${folderId}/${contentId}`;
const videoProgressPercent = Math.round(
(currentTimestamp / videoDuration) * 100,
);
return (
<ContentCard
type={type}
key={id}
title={title}
image={thumbnail || ''}
onClick={() => {
router.push(videoUrl);
}}
videoProgressPercent={videoProgressPercent}
hoverExpand={false}
/>
);
}
};

export default WatchHistoryClient;
Loading

0 comments on commit d14337b

Please sign in to comment.