feat/course
Arik Chakma 1 month ago
parent ac6aab1fe1
commit fad187b862
  1. 133
      src/components/Course/CertificateView.tsx
  2. 131
      src/components/Course/Chapter.tsx
  3. 35
      src/components/Course/CourseLayout.tsx
  4. 28
      src/components/Course/CourseSidebar.tsx
  5. 72
      src/components/Course/RateCourseForm.tsx
  6. 7
      src/pages/learn/[courseId]/[chapterId]/[lessonId].astro
  7. 8
      src/pages/learn/[courseId]/certificate.astro

@ -1,42 +1,121 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Rating } from '../Rating/Rating'; import { Rating } from '../Rating/Rating';
import { RateCourseForm } from './RateCourseForm';
import type { ChapterFileType } from '../../lib/course';
import { useCourseProgress } from '../../hooks/use-course';
import { Loader2 } from 'lucide-react';
type CertificateViewProps = { type CertificateViewProps = {
chapters: ChapterFileType[];
currentCourseId: string; currentCourseId: string;
}; };
export function CertificateView(props: CertificateViewProps) { export function CertificateView(props: CertificateViewProps) {
const { currentCourseId } = props; const { currentCourseId, chapters } = props;
const [isLoading, setIsLoading] = useState(true);
const { data: courseProgress, status } = useCourseProgress(currentCourseId);
const completeLessonSet = useMemo(
() =>
new Set(
(courseProgress?.completed || []).map(
(l) => `/learn/${currentCourseId}/${l.chapterId}/${l.lessonId}`,
),
),
[courseProgress],
);
const allLessonLinks = useMemo(() => {
const lessons: string[] = [];
for (const chapter of chapters) {
for (const lesson of chapter.lessons) {
lessons.push(`/learn/${currentCourseId}/${chapter.id}/${lesson.id}`);
}
}
return lessons;
}, [chapters]);
const isCourseCompleted = useMemo(() => {
return allLessonLinks.every((lessonLink) =>
completeLessonSet.has(lessonLink),
);
}, [allLessonLinks, completeLessonSet]);
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [showRatingForm, setShowRatingForm] = useState(false);
return ( useEffect(() => {
<div className="mx-auto flex max-w-md flex-col items-center justify-center"> if (!courseProgress) {
<div className="flex flex-col items-center"> return;
<h1 className="text-4xl font-semibold">Congratulations!</h1> }
<p className="mt-3 text-center text-lg text-zinc-200">
You finished the course. Download the completion certificate below and setIsLoading(false);
share it with the world. }, [courseProgress]);
</p>
<button>
<a
target="_blank"
rel="noreferrer"
className="mt-8 block rounded-full bg-zinc-700 px-6 py-2.5 font-medium text-white"
>
Download Certificate
</a>
</button>
</div>
<div className="mt-24 flex flex-col items-center gap-3"> return (
<Rating <>
rating={rating} {showRatingForm && (
onRatingChange={(rating) => setRating(rating)} <RateCourseForm
starSize={36} defaultRating={rating}
onClose={() => {
setRating(0);
setShowRatingForm(false);
}}
/> />
<span>Rate your experience</span> )}
<div className="mx-auto flex max-w-md flex-col items-center justify-center">
{isLoading && (
<Loader2 className="size-8 animate-spin stroke-[2.5] text-zinc-200" />
)}
{isCourseCompleted && !isLoading && (
<>
<div className="flex flex-col items-center">
<h1 className="text-4xl font-semibold">Congratulations!</h1>
<p className="mt-3 text-center text-lg text-zinc-200">
You finished the course. Download the completion certificate
below and share it with the world.
</p>
<button>
<a
target="_blank"
rel="noreferrer"
className="mt-8 block rounded-full bg-zinc-700 px-6 py-2.5 font-medium text-white"
>
Download Certificate
</a>
</button>
</div>
<div className="mt-24 flex flex-col items-center gap-3">
<Rating
key={rating}
rating={rating}
onRatingChange={(rating) => {
setRating(rating);
setShowRatingForm(true);
}}
starSize={36}
/>
<span>Rate your experience</span>
</div>
</>
)}
{!isCourseCompleted && !isLoading && (
<div className="flex flex-col items-center">
<h1 className="text-4xl font-semibold">Almost there!</h1>
<p className="mt-3 text-center text-lg text-zinc-200">
Complete the course to download the certificate and rate your
experience.
</p>
</div>
)}
</div> </div>
</div> </>
); );
} }

@ -12,44 +12,46 @@ type ChapterProps = ChapterFileType & {
isActive?: boolean; isActive?: boolean;
isCompleted?: boolean; isCompleted?: boolean;
currentCourseId: string; activeCourseId: string;
currentChapterId?: string; activeChapterId?: string;
currentLessonId?: string; activeLessonId?: string;
onChapterClick?: () => void; onChapterClick?: () => void;
}; };
export function Chapter(props: ChapterProps) { export function Chapter(props: ChapterProps) {
const { const {
id: chapterId,
index, index,
frontmatter, frontmatter,
lessons, lessons,
isActive = false, isActive = false,
onChapterClick, onChapterClick,
currentCourseId, activeCourseId,
currentChapterId, activeChapterId,
currentLessonId, activeLessonId,
} = props; } = props;
const { title } = frontmatter; const { title } = frontmatter;
const { data: courseProgress } = useCourseProgress(currentCourseId); const { data: courseProgress } = useCourseProgress(activeCourseId);
const completeLessonSet = useMemo( const completeLessonSet = useMemo(
() => () =>
new Set( new Set(
(courseProgress?.completed || []) (courseProgress?.completed || [])
.filter((l) => l.chapterId === currentChapterId) .filter((l) => l.chapterId === chapterId)
.map((l) => `${l.chapterId}/${l.lessonId}`), .map((l) => `${l.chapterId}/${l.lessonId}`),
), ),
[courseProgress], [courseProgress],
); );
const isChapterCompleted = lessons.every((lesson) => const isChapterCompleted = lessons.every((lesson) =>
completeLessonSet.has(`${currentChapterId}/${lesson.id}`), completeLessonSet.has(`${chapterId}/${lesson.id}`),
); );
const completedPercentage = useMemo(() => { const completedPercentage = useMemo(() => {
const completedCount = lessons.filter((lesson) => const completedCount = lessons.filter((lesson) =>
completeLessonSet.has(`${currentChapterId}/${lesson.id}`), completeLessonSet.has(`${chapterId}/${lesson.id}`),
).length; ).length;
return getPercentage(completedCount, lessons.length); return getPercentage(completedCount, lessons.length);
@ -101,25 +103,14 @@ export function Chapter(props: ChapterProps) {
<div className="flex flex-col border-b border-zinc-800"> <div className="flex flex-col border-b border-zinc-800">
{lessons.length > 0 && ( {lessons.length > 0 && (
<> <>
<div> <LessonList
{filteredLessons?.map((lesson) => { activeCourseId={activeCourseId}
const isActive = currentLessonId === lesson.id; activeChapterId={activeChapterId}
const isCompleted = completeLessonSet.has( activeLessonId={activeLessonId}
`${currentChapterId}/${lesson.id}`, chapterId={chapterId}
); lessons={filteredLessons}
completedLessonSet={completeLessonSet}
return ( />
<Lesson
key={lesson.id}
{...lesson}
currentCourseId={currentCourseId}
currentChapterId={currentChapterId}
isActive={isActive}
isCompleted={isCompleted}
/>
);
})}
</div>
<div className="relative"> <div className="relative">
<label className="relative z-10 my-2 ml-2 block max-w-max rounded-md bg-zinc-800 p-1 px-2 text-xs"> <label className="relative z-10 my-2 ml-2 block max-w-max rounded-md bg-zinc-800 p-1 px-2 text-xs">
@ -129,25 +120,14 @@ export function Chapter(props: ChapterProps) {
<span className="absolute left-[17px] top-0 h-full w-0.5 bg-zinc-700"></span> <span className="absolute left-[17px] top-0 h-full w-0.5 bg-zinc-700"></span>
</div> </div>
<div> <LessonList
{exercises?.map((exercise) => { activeCourseId={activeCourseId}
const isActive = currentLessonId === exercise.id; activeChapterId={activeChapterId}
const isCompleted = completeLessonSet.has( activeLessonId={activeLessonId}
`${currentChapterId}/${exercise.id}`, chapterId={chapterId}
); lessons={exercises}
completedLessonSet={completeLessonSet}
return ( />
<Lesson
key={exercise.id}
{...exercise}
currentCourseId={currentCourseId}
currentChapterId={currentChapterId}
isActive={isActive}
isCompleted={isCompleted}
/>
);
})}
</div>
</> </>
)} )}
@ -160,28 +140,69 @@ export function Chapter(props: ChapterProps) {
); );
} }
type LessonProps = LessonFileType & { type LessonListProps = {
currentCourseId: string; activeCourseId: string;
currentChapterId?: string; activeChapterId?: string;
activeLessonId?: string;
chapterId: string;
lessons: LessonFileType[];
completedLessonSet: Set<string>;
};
function LessonList(props: LessonListProps) {
const {
activeCourseId,
activeChapterId,
activeLessonId,
chapterId,
lessons,
completedLessonSet,
} = props;
return (
<div>
{lessons.map((lesson) => {
const isActive =
activeLessonId === lesson.id && chapterId === activeChapterId;
const isCompleted = completedLessonSet.has(`${chapterId}/${lesson.id}`);
return (
<Lesson
key={lesson.id}
{...lesson}
courseId={activeCourseId}
chapterId={chapterId}
isActive={isActive}
isCompleted={isCompleted}
/>
);
})}
</div>
);
}
type LessonProps = LessonFileType & {
isActive?: boolean; isActive?: boolean;
isCompleted?: boolean; isCompleted?: boolean;
courseId: string;
chapterId: string;
}; };
export function Lesson(props: LessonProps) { export function Lesson(props: LessonProps) {
const { const {
frontmatter, frontmatter,
isActive, isActive,
currentCourseId, courseId,
currentChapterId, chapterId,
id: lessonId, id: lessonId,
isCompleted, isCompleted,
} = props; } = props;
const { title } = frontmatter; const { title } = frontmatter;
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const { isLoading } = useCourseProgress(currentCourseId); const { isLoading } = useCourseProgress(courseId);
const href = `/learn/${currentCourseId}/${currentChapterId}/${lessonId}`; const href = `/learn/${courseId}/${chapterId}/${lessonId}`;
return ( return (
<a <a

@ -17,25 +17,20 @@ type CourseLayoutProps = {
export function CourseLayout(props: CourseLayoutProps) { export function CourseLayout(props: CourseLayoutProps) {
const { children, ...sidebarProps } = props; const { children, ...sidebarProps } = props;
const { const { chapters, activeCourseId, activeChapterId, activeLessonId, lesson } =
chapters, sidebarProps;
currentCourseId,
currentChapterId,
currentLessonId,
lesson,
} = sidebarProps;
const $currentLesson = useStore(currentLesson); const $currentLesson = useStore(currentLesson);
const [showNextWarning, setShowNextWarning] = useState(false); const [showNextWarning, setShowNextWarning] = useState(false);
const { data: courseProgress } = useCourseProgress(currentCourseId); const { data: courseProgress } = useCourseProgress(activeCourseId);
const completeLesson = useCompleteLessonMutation(currentCourseId); const completeLesson = useCompleteLessonMutation(activeCourseId);
const completeLessonSet = useMemo( const completeLessonSet = useMemo(
() => () =>
new Set( new Set(
(courseProgress?.completed || []).map( (courseProgress?.completed || []).map(
(l) => `/learn/${currentCourseId}/${l.chapterId}/${l.lessonId}`, (l) => `/learn/${activeCourseId}/${l.chapterId}/${l.lessonId}`,
), ),
), ),
[courseProgress], [courseProgress],
@ -45,7 +40,7 @@ export function CourseLayout(props: CourseLayoutProps) {
const lessons: string[] = []; const lessons: string[] = [];
for (const chapter of chapters) { for (const chapter of chapters) {
for (const lesson of chapter.lessons) { for (const lesson of chapter.lessons) {
lessons.push(`/learn/${currentCourseId}/${chapter.id}/${lesson.id}`); lessons.push(`/learn/${activeCourseId}/${chapter.id}/${lesson.id}`);
} }
} }
@ -60,7 +55,7 @@ export function CourseLayout(props: CourseLayoutProps) {
return getPercentage(completedCount, allLessonLinks.length); return getPercentage(completedCount, allLessonLinks.length);
}, [allLessonLinks, completeLessonSet]); }, [allLessonLinks, completeLessonSet]);
const currentLessonUrl = `/learn/${currentCourseId}/${currentChapterId}/${currentLessonId}`; const currentLessonUrl = `/learn/${activeCourseId}/${activeChapterId}/${activeLessonId}`;
const isCurrentLessonCompleted = completeLessonSet.has(currentLessonUrl); const isCurrentLessonCompleted = completeLessonSet.has(currentLessonUrl);
const currentLessonIndex = allLessonLinks.indexOf(currentLessonUrl); const currentLessonIndex = allLessonLinks.indexOf(currentLessonUrl);
@ -75,14 +70,14 @@ export function CourseLayout(props: CourseLayoutProps) {
return; return;
} }
if (!currentChapterId || !currentLessonId) { if (!activeChapterId || !activeLessonId) {
return; return;
} }
completeLesson.mutate( completeLesson.mutate(
{ {
chapterId: currentChapterId, chapterId: activeChapterId,
lessonId: currentLessonId, lessonId: activeLessonId,
}, },
{ {
onSuccess: () => { onSuccess: () => {
@ -102,9 +97,9 @@ export function CourseLayout(props: CourseLayoutProps) {
} }
currentLesson.set({ currentLesson.set({
courseId: currentCourseId, courseId: activeCourseId,
chapterId: currentChapterId, chapterId: activeChapterId,
lessonId: currentLessonId, lessonId: activeLessonId,
lessonType: lesson?.frontmatter?.type, lessonType: lesson?.frontmatter?.type,
challengeStatus: 'pending', challengeStatus: 'pending',
quizStatus: 'pending', quizStatus: 'pending',
@ -126,7 +121,7 @@ export function CourseLayout(props: CourseLayoutProps) {
<section <section
className={cn( className={cn(
'grid h-screen grid-rows-[1fr_60px] overflow-hidden bg-zinc-900 text-zinc-50', 'grid h-screen grid-rows-[1fr_60px] overflow-hidden bg-zinc-900 text-zinc-50',
currentChapterId && currentLessonId activeChapterId && activeLessonId
? 'grid-rows-[1fr_60px]' ? 'grid-rows-[1fr_60px]'
: 'grid-rows-1', : 'grid-rows-1',
)} )}
@ -140,7 +135,7 @@ export function CourseLayout(props: CourseLayoutProps) {
{children} {children}
</div> </div>
{currentChapterId && currentLessonId && ( {activeChapterId && activeLessonId && (
<footer className="flex items-center justify-end border-t border-zinc-800 px-4"> <footer className="flex items-center justify-end border-t border-zinc-800 px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button

@ -4,9 +4,9 @@ import { Chapter } from './Chapter';
import { StickyNote } from 'lucide-react'; import { StickyNote } from 'lucide-react';
export type CourseSidebarProps = { export type CourseSidebarProps = {
currentCourseId: string; activeCourseId: string;
currentChapterId?: string; activeChapterId?: string;
currentLessonId?: string; activeLessonId?: string;
title: string; title: string;
chapters: ChapterFileType[]; chapters: ChapterFileType[];
@ -20,14 +20,14 @@ export function CourseSidebar(props: CourseSidebarProps) {
title, title,
chapters, chapters,
completedPercentage, completedPercentage,
currentCourseId, activeCourseId,
currentChapterId, activeChapterId,
currentLessonId, activeLessonId,
} = props; } = props;
const [activeChapterId, setActiveChapterId] = useState(currentChapterId); const [openedChapterId, setOpenedChapterId] = useState(activeChapterId);
const ceritificateUrl = `/learn/${currentCourseId}/certificate`; const ceritificateUrl = `/learn/${activeCourseId}/certificate`;
return ( return (
<aside className="border-r border-zinc-800"> <aside className="border-r border-zinc-800">
@ -48,7 +48,7 @@ export function CourseSidebar(props: CourseSidebarProps) {
<div className="relative h-full"> <div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]"> <div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
{chapters?.map((chapter, index) => { {chapters?.map((chapter, index) => {
const isActive = activeChapterId === chapter.id; const isActive = openedChapterId === chapter.id;
return ( return (
<Chapter <Chapter
@ -56,16 +56,16 @@ export function CourseSidebar(props: CourseSidebarProps) {
isActive={isActive} isActive={isActive}
onChapterClick={() => { onChapterClick={() => {
if (isActive) { if (isActive) {
setActiveChapterId(''); setOpenedChapterId('');
} else { } else {
setActiveChapterId(chapter.id); setOpenedChapterId(chapter.id);
} }
}} }}
index={index + 1} index={index + 1}
{...chapter} {...chapter}
currentCourseId={currentCourseId} activeCourseId={activeCourseId}
currentChapterId={currentChapterId} activeChapterId={activeChapterId}
currentLessonId={currentLessonId} activeLessonId={activeLessonId}
/> />
); );
})} })}

@ -0,0 +1,72 @@
import { useState } from 'react';
import { Modal } from '../Modal';
import { Rating } from '../Rating/Rating';
import { cn } from '../../lib/classname';
type RateCourseFormProps = {
defaultRating?: number;
onClose: () => void;
};
export function RateCourseForm(props: RateCourseFormProps) {
const { onClose, defaultRating = 0 } = props;
const [userRating, setUserRating] = useState(defaultRating);
const [userFeedback, setUserFeedback] = useState('');
return (
<Modal onClose={onClose} bodyClassName="bg-zinc-800 p-5 rounded-lg">
<h3 className="font-semibold">Rate this Course</h3>
<p className="mt-1 text-sm">Share your thoughts with us.</p>
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault();
}}
>
<Rating
rating={userRating}
onRatingChange={(rating) => {
setUserRating(rating);
}}
starSize={32}
/>
<div className="mt-3 flex flex-col gap-1">
<label
htmlFor="rating-feedback"
className="block text-sm font-medium"
>
Feedback{' '}
<span className="font-normal text-zinc-400">(Optional)</span>
</label>
<textarea
id="rating-feedback"
className="min-h-24 rounded-md border border-zinc-700 bg-transparent p-2 text-sm outline-none focus:border-zinc-500"
placeholder="Share your thoughts with the roadmap creator"
value={userFeedback}
onChange={(e) => {
setUserFeedback(e.target.value);
}}
/>
</div>
<div className={cn('mt-4 grid grid-cols-2 gap-1')}>
<button
className="h-10 w-full rounded-full border border-zinc-700 p-2.5 text-sm font-medium hover:bg-zinc-700/50 hover:text-white disabled:opacity-60"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex h-10 w-full items-center justify-center rounded-full bg-white p-2.5 text-sm font-medium text-black hover:bg-zinc-100 disabled:opacity-60"
type="submit"
>
Submit Rating
</button>
</div>
</form>
</Modal>
);
}

@ -73,13 +73,12 @@ const { course, chapter, lesson } = Astro.props;
<SkeletonLayout title={course.frontmatter.title}> <SkeletonLayout title={course.frontmatter.title}>
<CourseLayout <CourseLayout
courseId={courseId} activeCourseId={courseId}
chapterId={chapterId} activeChapterId={chapterId}
lessonId={lesson.id} activeLessonId={lesson.id}
lesson={lesson} lesson={lesson}
title={course.frontmatter.title} title={course.frontmatter.title}
chapters={course.chapters} chapters={course.chapters}
completedPercentage={0}
client:load client:load
> >
{ {

@ -56,11 +56,15 @@ const { course } = Astro.props;
<SkeletonLayout title={course.frontmatter.title}> <SkeletonLayout title={course.frontmatter.title}>
<CourseLayout <CourseLayout
currentCourseId={courseId} activeCourseId={courseId}
title={course.frontmatter.title} title={course.frontmatter.title}
chapters={course.chapters} chapters={course.chapters}
client:load client:load
> >
<CertificateView currentCourseId={courseId} client:load /> <CertificateView
chapters={course.chapters}
currentCourseId={courseId}
client:load
/>
</CourseLayout> </CourseLayout>
</SkeletonLayout> </SkeletonLayout>

Loading…
Cancel
Save