Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Feature: User Learning Progress Tracking and Chapter Resume Option #672

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/components/LessonView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import RedirectToLoginCard from "./RedirectToLoginCard";
import { getServerSession } from "next-auth";
import { authOptions } from "../lib/auth";
import { AppbarClient } from "./AppbarClient";
import { setProgress } from "./utils";

export const LessonView = async ({
problem,
Expand Down Expand Up @@ -34,6 +35,11 @@ export const LessonView = async ({
);
}

// Update progress when the lesson is viewed
if (session?.user) {
await setProgress(track.id, problem.id, true);
}

if (problem.type === "MCQ") {
return <MCQRenderer problem={problem} track={track} showAppBar={!!showAppBar} problemIndex={problemIndex} />;
}
Expand Down
77 changes: 43 additions & 34 deletions apps/web/components/TrackCard-2.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from "react";
"use client";

import { useRef, useState } from "react";
import { motion, useAnimation } from "framer-motion";
import { Track, Problem } from "@prisma/client";
import { Track, Problem, UserProgress } from "@prisma/client";
import { TrackPreview } from "./TrackPreview";
import { formatDistanceToNow } from "date-fns";
import { useSession } from "next-auth/react";

interface TrackCardProps extends Track {
problems: Problem[];
Expand All @@ -14,71 +17,77 @@ interface TrackCardProps extends Track {
}[];
}

export function TrackCard2({ track }: { track: TrackCardProps }) {
export function TrackCard2({ track, userProgress }: { track: TrackCardProps; userProgress: UserProgress[] }) {
const controls = useAnimation();
const ref = useRef<HTMLDivElement | null>(null);
const [showPreview, setShowPreview] = useState<boolean>(false);

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry && entry.isIntersecting) {
controls.start("visible");
}
},
{ threshold: 0.3 }
);

if (ref.current) {
observer.observe(ref.current);
}

return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [controls]);
const { data: session } = useSession();

const variants = {
hidden: { opacity: 0 },
// hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1, ease: "easeOut" } },
};

const completedProblems = userProgress.filter((p) => p.completed).length;
const progressPercentage = (completedProblems / track.problems.length) * 100;
const isCompleted = progressPercentage === 100;

const lastVisitedProblem =
userProgress.length > 0
? userProgress.reduce((prev, current) => (prev.lastVisited > current.lastVisited ? prev : current))
: null;

return (
<>
<motion.div
ref={ref}
initial="hidden"
animate={controls}
variants={variants}
className="flex items-start flex-row gap-4 cursor-pointer transition-all bg-primary/5 backdrop-blur-xl duration-300 hover:-translate-y-1 rounded-xl p-4 justify-between md:items-center"
className="bg-primary/5 flex cursor-pointer flex-row items-start justify-between gap-4 rounded-xl p-4 backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 md:items-center"
onClick={() => setShowPreview(true)}
>
<img src={track.image} alt={track.title} className="size-20 aspect-square object-cover rounded-xl" />
<div className="flex flex-col md:flex-row gap-4 w-full md:items-center justify-between">
<img src={track.image} alt={track.title} className="aspect-square size-20 rounded-xl object-cover" />
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="flex flex-col gap-2">
<h3 className="text-xl md:text-2xl tracking-tighter font-semibold lg:line-clamp-1">{track.title}</h3>
<h3 className="text-xl font-semibold tracking-tighter md:text-2xl lg:line-clamp-1">{track.title}</h3>
{track.categories.map((item) => (
<p
key={item.category.id}
className="bg-secondary/25 border border-primary/10 rounded-lg px-3 py-2 text-sm w-fit cursor-default"
className="bg-secondary/25 border-primary/10 w-fit cursor-default rounded-lg border px-3 py-2 text-sm"
>
{item.category.category}
</p>
))}
</div>
<div className="flex flex-row md:flex-col gap-2 w-full md:w-[30%] md:items-end items-center">
<p className="text-primary/80 md:text-lg tracking-tight text-blue-500 font-semibold">
<div className="flex w-full flex-row items-center gap-2 md:w-[30%] md:flex-col md:items-end">
<p className="text-primary/80 font-semibold tracking-tight text-blue-500 md:text-lg">
{track.problems.length} Chapters
</p>
<p className="flex tracking-tight gap-2 text-primary/60 text-sm md:text-base">
<p className="text-primary/60 flex gap-2 text-sm tracking-tight md:text-base">
{formatDistanceToNow(new Date(track.createdAt), { addSuffix: true })}
</p>
{(session?.user || userProgress.length > 0) && (
<div className="flex items-center gap-2">
<div className="h-2.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-2.5 rounded-full bg-blue-600" style={{ width: `${progressPercentage}%` }}></div>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{progressPercentage.toFixed(0)}%
</span>
</div>
)}
</div>
</div>
</motion.div>
<TrackPreview showPreview={showPreview} setShowPreview={setShowPreview} track={track} />
<TrackPreview
showPreview={showPreview}
setShowPreview={setShowPreview}
track={track}
isCompleted={isCompleted}
lastVisitedProblem={lastVisitedProblem}
userProgress={userProgress}
/>
</>
);
}
136 changes: 96 additions & 40 deletions apps/web/components/TrackPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
"use client";

import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Button } from "../../../packages/ui/src/shad/ui/button";
import { Dialog, DialogContent } from "../../../packages/ui/src/shad/ui/dailog";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Play, Redo, CheckCircle } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Track, Problem, UserProgress, Categories } from "@prisma/client";
import { useSession } from "next-auth/react";
import { Button, Dialog, DialogContent } from "@repo/ui";

type TrackPreviewProps = {
interface TrackPreviewProps {
showPreview: boolean;
setShowPreview: (val: boolean) => void;
track: any;
};
track: Track & {
problems: Problem[];
categories: {
category: Categories;
}[];
};
isCompleted: boolean;
lastVisitedProblem: UserProgress | null;
userProgress: UserProgress[];
}

const truncateDescription = (text: string, wordLimit: number) => {
const words = text.split(" ");
Expand All @@ -20,74 +30,120 @@ const truncateDescription = (text: string, wordLimit: number) => {
return text;
};

export function TrackPreview({ showPreview, setShowPreview, track }: TrackPreviewProps) {
export function TrackPreview({
showPreview,
setShowPreview,
track,
isCompleted,
lastVisitedProblem,
userProgress,
}: TrackPreviewProps) {
const [isMediumOrLarger, setIsMediumOrLarger] = useState(false);

const updateScreenSize = () => {
setIsMediumOrLarger(window.innerWidth >= 768);
};
const { data: session } = useSession();
const [completedProblems, setCompletedProblems] = useState<string[]>([]);
const [lastVisited, setLastVisited] = useState<string | null>(null);

useEffect(() => {
updateScreenSize(); // Set the initial state
window.addEventListener("resize", updateScreenSize); // Add resize listener
const updateScreenSize = () => {
setIsMediumOrLarger(window.innerWidth >= 768);
};

updateScreenSize();
window.addEventListener("resize", updateScreenSize);

return () => {
window.removeEventListener("resize", updateScreenSize); // Cleanup listener on unmount
window.removeEventListener("resize", updateScreenSize);
};
}, []);

useEffect(() => {
if (session?.user) {
setCompletedProblems(userProgress.filter((p) => p.completed).map((p) => p.problemId));
setLastVisited(lastVisitedProblem?.problemId || null);
}
}, [session, track.id, userProgress, lastVisitedProblem]);

const truncatedDescription = isMediumOrLarger ? track.description : truncateDescription(track.description, 15);

const progressPercentage = (completedProblems.length / track.problems.length) * 100;

return (
<Dialog open={showPreview} onOpenChange={() => setShowPreview(false)}>
<DialogContent className="flex items-center gap-4">
<div className="flex flex-col gap-4 w-full">
<img src={track.image} className="h-[25vh] w-full object-cover rounded-lg" />
<div className="flex flex-col gap-4 bg-primary/5 rounded-lg p-4">
<div className="flex w-full flex-col gap-4">
<img src={track.image} className="h-[25vh] w-full rounded-lg object-cover" alt={track.title} />
<div className="bg-primary/5 flex flex-col gap-4 rounded-lg p-4">
<div className="flex flex-col gap-4">
<h3 className="text-xl md:text-2xl font-semibold w-full tracking-tight">{track.title}</h3>
<h3 className="w-full text-xl font-semibold tracking-tight md:text-2xl">{track.title}</h3>
<div className="flex items-center gap-4">
{track.categories.map((item: any, idx: number) => (
{track.categories.map((item) => (
<p
key={item.category.id}
className="bg-secondary/25 border border-primary/10 rounded-lg px-3 py-2 text-sm w-fit cursor-default"
className="bg-secondary/25 border-primary/10 w-fit cursor-default rounded-lg border px-3 py-2 text-sm"
>
{item.category.category}{" "}
{item.category.category}
</p>
))}
</div>
</div>
<p className="md:text-lg tracking-tighter line-clamp-3 text-primary/60">{truncatedDescription}</p>
<p className="text-primary/60 line-clamp-3 tracking-tighter md:text-lg">{truncatedDescription}</p>
</div>
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-2 items-center">
<p className="flex tracking-tighter gap-2 text-primary text-lg md:text-xl font-semibold">
<div className="flex w-full flex-col gap-4">
<div className="flex items-center gap-2">
<p className="text-primary flex gap-2 text-lg font-semibold tracking-tighter md:text-xl">
{track.problems.length} Chapters
</p>
<p className="flex tracking-tight gap-2 text-primary/60 md:text-lg">
<p className="text-primary/60 flex gap-2 tracking-tight md:text-lg">
{formatDistanceToNow(new Date(track.createdAt), { addSuffix: true })}
</p>
</div>
<div className="max-h-[25vh] overflow-y-auto flex flex-col gap-3 w-full py-2">
{track.problems.map((topic: any, idx: number) => (
<Link key={topic.id} href={`/tracks/${track.id}/${track.problems[idx]?.id}`}>
<div className="cursor-pointer hover:-translate-y-1 flex items-center justify-between bg-primary/5 rounded-lg px-4 py-3 hover:bg-primary/10 transition-all duration-300 scroll-smooth w-full">
<div className="flex max-h-[25vh] w-full flex-col gap-3 overflow-y-auto py-2">
{track.problems.map((topic) => (
<Link key={topic.id} href={`/tracks/${track.id}/${topic.id}`}>
<div className="bg-primary/5 hover:bg-primary/10 flex w-full cursor-pointer items-center justify-between scroll-smooth rounded-lg px-4 py-3 transition-all duration-300 hover:-translate-y-1">
{topic.title}
<ArrowRight className="size-4" />
{completedProblems.includes(topic.id) ? (
<CheckCircle className="size-4 text-green-500" />
) : (
<ArrowRight className="size-4" />
)}
</div>
</Link>
))}
</div>
</div>
<Link href={track.problems.length ? `/tracks/${track.id}/${track.problems[0]?.id}` : ""}>
<Button
size={"lg"}
className="flex items-center justify-center bg-blue-600 text-white hover:bg-blue-500 transition-all duration-300"
onClick={(e) => e.stopPropagation()}
>
Start
</Button>
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2.5 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-2.5 rounded-full bg-blue-600" style={{ width: `${progressPercentage}%` }}></div>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{progressPercentage.toFixed(0)}% Complete
</span>
</div>
<div className="flex gap-2">
<Link href={`/tracks/${track.id}/${track.problems[0]?.id}`}>
<Button size="sm" variant="outline" className="flex items-center gap-2">
<Play className="size-4" />
Start from beginning
</Button>
</Link>
{lastVisited && lastVisited !== track.problems[0]?.id && (
<Link href={`/tracks/${track.id}/${lastVisited}`}>
<Button size="sm" variant="outline" className="flex items-center gap-2">
<Redo className="size-4" />
Resume
</Button>
</Link>
)}
{isCompleted && (
<Button size="sm" variant="outline" className="flex items-center gap-2" disabled>
<CheckCircle className="size-4" />
Completed
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
Expand Down
Loading
Loading