feat/course
Arik Chakma 4 weeks 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 { RateCourseForm } from './RateCourseForm';
import type { ChapterFileType } from '../../lib/course';
import { useCourseProgress } from '../../hooks/use-course';
import { Loader2 } from 'lucide-react';
type CertificateViewProps = {
chapters: ChapterFileType[];
currentCourseId: string;
};
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 [showRatingForm, setShowRatingForm] = useState(false);
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center">
<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>
useEffect(() => {
if (!courseProgress) {
return;
}
setIsLoading(false);
}, [courseProgress]);
<div className="mt-24 flex flex-col items-center gap-3">
<Rating
rating={rating}
onRatingChange={(rating) => setRating(rating)}
starSize={36}
return (
<>
{showRatingForm && (
<RateCourseForm
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>
</>
);
}

@ -12,44 +12,46 @@ type ChapterProps = ChapterFileType & {
isActive?: boolean;
isCompleted?: boolean;
currentCourseId: string;
currentChapterId?: string;
currentLessonId?: string;
activeCourseId: string;
activeChapterId?: string;
activeLessonId?: string;
onChapterClick?: () => void;
};
export function Chapter(props: ChapterProps) {
const {
id: chapterId,
index,
frontmatter,
lessons,
isActive = false,
onChapterClick,
currentCourseId,
currentChapterId,
currentLessonId,
activeCourseId,
activeChapterId,
activeLessonId,
} = props;
const { title } = frontmatter;
const { data: courseProgress } = useCourseProgress(currentCourseId);
const { data: courseProgress } = useCourseProgress(activeCourseId);
const completeLessonSet = useMemo(
() =>
new Set(
(courseProgress?.completed || [])
.filter((l) => l.chapterId === currentChapterId)
.filter((l) => l.chapterId === chapterId)
.map((l) => `${l.chapterId}/${l.lessonId}`),
),
[courseProgress],
);
const isChapterCompleted = lessons.every((lesson) =>
completeLessonSet.has(`${currentChapterId}/${lesson.id}`),
completeLessonSet.has(`${chapterId}/${lesson.id}`),
);
const completedPercentage = useMemo(() => {
const completedCount = lessons.filter((lesson) =>
completeLessonSet.has(`${currentChapterId}/${lesson.id}`),
completeLessonSet.has(`${chapterId}/${lesson.id}`),
).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">
{lessons.length > 0 && (
<>
<div>
{filteredLessons?.map((lesson) => {
const isActive = currentLessonId === lesson.id;
const isCompleted = completeLessonSet.has(
`${currentChapterId}/${lesson.id}`,
);
return (
<Lesson
key={lesson.id}
{...lesson}
currentCourseId={currentCourseId}
currentChapterId={currentChapterId}
isActive={isActive}
isCompleted={isCompleted}
/>
);
})}
</div>
<LessonList
activeCourseId={activeCourseId}
activeChapterId={activeChapterId}
activeLessonId={activeLessonId}
chapterId={chapterId}
lessons={filteredLessons}
completedLessonSet={completeLessonSet}
/>
<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">
@ -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>
</div>
<div>
{exercises?.map((exercise) => {
const isActive = currentLessonId === exercise.id;
const isCompleted = completeLessonSet.has(
`${currentChapterId}/${exercise.id}`,
);
return (
<Lesson
key={exercise.id}
{...exercise}
currentCourseId={currentCourseId}
currentChapterId={currentChapterId}
isActive={isActive}
isCompleted={isCompleted}
/>
);
})}
</div>
<LessonList
activeCourseId={activeCourseId}
activeChapterId={activeChapterId}
activeLessonId={activeLessonId}
chapterId={chapterId}
lessons={exercises}
completedLessonSet={completeLessonSet}
/>
</>
)}
@ -160,28 +140,69 @@ export function Chapter(props: ChapterProps) {
);
}
type LessonProps = LessonFileType & {
currentCourseId: string;
currentChapterId?: string;
type LessonListProps = {
activeCourseId: 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;
isCompleted?: boolean;
courseId: string;
chapterId: string;
};
export function Lesson(props: LessonProps) {
const {
frontmatter,
isActive,
currentCourseId,
currentChapterId,
courseId,
chapterId,
id: lessonId,
isCompleted,
} = props;
const { title } = frontmatter;
const isMounted = useIsMounted();
const { isLoading } = useCourseProgress(currentCourseId);
const href = `/learn/${currentCourseId}/${currentChapterId}/${lessonId}`;
const { isLoading } = useCourseProgress(courseId);
const href = `/learn/${courseId}/${chapterId}/${lessonId}`;
return (
<a

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

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

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

Loading…
Cancel
Save