From e11ac4bf847df2dc13818c6b1f82778b6644a03a Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 22 Oct 2024 14:28:44 +0600 Subject: [PATCH] feat: course progress --- .astro/settings.json | 2 +- src/components/Course/Chapter.tsx | 99 +++++++--- src/components/Course/CourseLayout.tsx | 171 +++++++++++++----- src/components/Course/CourseSidebar.tsx | 7 +- .../Course/NextLessonAlertModal.tsx | 37 ++++ src/components/Course/QuizView.tsx | 6 +- .../SqlCodeEditor/SqlCodeEditor.tsx | 133 ++++++++------ .../SqlCodeEditor/SqlTableResult.tsx | 45 +---- src/hooks/use-course.ts | 52 ++++++ src/lib/query-http.ts | 146 +++++++++++++++ src/stores/course.ts | 4 + 11 files changed, 519 insertions(+), 183 deletions(-) create mode 100644 src/components/Course/NextLessonAlertModal.tsx create mode 100644 src/hooks/use-course.ts create mode 100644 src/lib/query-http.ts create mode 100644 src/stores/course.ts diff --git a/.astro/settings.json b/.astro/settings.json index ceb2ed8a8..2045f91c5 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1728296475293 + "lastUpdateCheck": 1729575132842 } } \ No newline at end of file diff --git a/src/components/Course/Chapter.tsx b/src/components/Course/Chapter.tsx index 3ec806e4b..13f1e43ce 100644 --- a/src/components/Course/Chapter.tsx +++ b/src/components/Course/Chapter.tsx @@ -1,7 +1,11 @@ -import { Check } from 'lucide-react'; +import { Check, Loader2 } from 'lucide-react'; import { cn } from '../../lib/classname'; import type { ChapterFileType, LessonFileType } from '../../lib/course'; -import { useMemo } from 'react'; +import { useMemo, type CSSProperties } from 'react'; +import { useCourseProgress } from '../../hooks/use-course'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { getPercentage } from '../../helper/number'; +import { useIsMounted } from '../../hooks/use-is-mounted'; type ChapterProps = ChapterFileType & { index: number; @@ -28,36 +32,50 @@ export function Chapter(props: ChapterProps) { } = props; const { title } = frontmatter; - const exercises = useMemo( - () => - lessons - ?.filter( - (lesson) => - lesson.frontmatter.type === 'quiz' || - lesson.frontmatter.type === 'challenge', - ) - ?.sort((a, b) => a.frontmatter.order - b.frontmatter.order) || [], - [lessons], - ); + const { data: courseProgress } = useCourseProgress(courseId); - const filteredLessons = useMemo( + const completeLessonSet = useMemo( () => - lessons - ?.filter((lesson) => - ['lesson', 'lesson-challenge', 'lesson-quiz'].includes( - lesson.frontmatter.type, - ), - ) - ?.sort((a, b) => a.frontmatter.order - b.frontmatter.order) || [], - [lessons], + new Set( + (courseProgress?.completed || []) + .filter((l) => l.chapterId === chapterId) + .map((l) => `${l.chapterId}/${l.lessonId}`), + ), + [courseProgress], + ); + const isChapterCompleted = lessons.every((lesson) => + completeLessonSet.has(`${chapterId}/${lesson.id}`), ); + const completedPercentage = useMemo(() => { + const completedCount = lessons.filter((lesson) => + completeLessonSet.has(`${chapterId}/${lesson.id}`), + ).length; + + return getPercentage(completedCount, lessons.length); + }, [lessons, completeLessonSet]); + + const [filteredLessons, exercises] = useMemo(() => { + const sortedLessons = lessons.sort( + (a, b) => a.frontmatter.order - b.frontmatter.order, + ); + + return [ + sortedLessons.filter( + (lesson) => !['quiz', 'challenge'].includes(lesson.frontmatter.type), + ), + sortedLessons.filter((lesson) => + ['quiz', 'challenge'].includes(lesson.frontmatter.type), + ), + ]; + }, [lessons]); + return (
{title} + {isChapterCompleted && lessons.length > 0 && ( + + )} + +
{isActive && ( @@ -74,6 +104,9 @@ export function Chapter(props: ChapterProps) {
{filteredLessons?.map((lesson) => { const isActive = lessonId === lesson.id; + const isCompleted = completeLessonSet.has( + `${chapterId}/${lesson.id}`, + ); return ( ); })} @@ -99,6 +132,9 @@ export function Chapter(props: ChapterProps) {
{exercises?.map((exercise) => { const isActive = lessonId === exercise.id; + const isCompleted = completeLessonSet.has( + `${chapterId}/${exercise.id}`, + ); return ( ); })} @@ -135,26 +171,31 @@ type LessonProps = LessonFileType & { export function Lesson(props: LessonProps) { const { frontmatter, - isCompleted, isActive, courseId, chapterId, id: lessonId, + isCompleted, } = props; const { title } = frontmatter; + const isMounted = useIsMounted(); + const { isLoading } = useCourseProgress(courseId); const href = `/learn/${courseId}/${chapterId}/${lessonId}`; return (
- {isCompleted && } + {isCompleted && } + {isLoading && isMounted && ( + + )}
{title} diff --git a/src/components/Course/CourseLayout.tsx b/src/components/Course/CourseLayout.tsx index 1b12cf60f..f0db0fa89 100644 --- a/src/components/Course/CourseLayout.tsx +++ b/src/components/Course/CourseLayout.tsx @@ -1,16 +1,39 @@ -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { CourseSidebar, type CourseSidebarProps } from './CourseSidebar'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import { + useCompleteLessonMutation, + useCourseProgress, +} from '../../hooks/use-course'; +import { NextLessonAlertModal } from './NextLessonAlertModal'; +import { useStore } from '@nanostores/react'; +import { lessonSubmitStatus } from '../../stores/course'; +import { getPercentage } from '../../helper/number'; type CourseLayoutProps = { - isSubmitted?: boolean; children: React.ReactNode; } & CourseSidebarProps; export function CourseLayout(props: CourseLayoutProps) { - const { children, isSubmitted, ...sidebarProps } = props; + const { children, ...sidebarProps } = props; const { chapters, courseId, chapterId, lessonId, lesson } = sidebarProps; + const $lessonSubmitStatus = useStore(lessonSubmitStatus); + const [showNextWarning, setShowNextWarning] = useState(false); + + const { data: courseProgress } = useCourseProgress(courseId); + const completeLesson = useCompleteLessonMutation(courseId); + + const completeLessonSet = useMemo( + () => + new Set( + (courseProgress?.completed || []).map( + (l) => `/learn/${courseId}/${l.chapterId}/${l.lessonId}`, + ), + ), + [courseProgress], + ); + const allLessonLinks = useMemo(() => { const lessons: string[] = []; for (const chapter of chapters) { @@ -22,53 +45,107 @@ export function CourseLayout(props: CourseLayoutProps) { return lessons; }, [chapters]); - const currentLessonIndex = allLessonLinks.indexOf( - `/learn/${courseId}/${chapterId}/${lessonId}`, - ); + const courseProgressPercentage = useMemo(() => { + const completedCount = allLessonLinks.filter((lessonLink) => + completeLessonSet.has(lessonLink), + ).length; + + return getPercentage(completedCount, allLessonLinks.length); + }, [allLessonLinks, completeLessonSet]); + + const currentLessonUrl = `/learn/${courseId}/${chapterId}/${lessonId}`; + const isCurrentLessonCompleted = completeLessonSet.has(currentLessonUrl); + + const currentLessonIndex = allLessonLinks.indexOf(currentLessonUrl); const prevLessonLink = allLessonLinks[currentLessonIndex - 1] || ''; const nextLessonLink = allLessonLinks[currentLessonIndex + 1] || ''; + const isCurrentLessonLast = currentLessonIndex === allLessonLinks.length - 1; + + const handleCompleteLesson = () => { + if (isCurrentLessonCompleted) { + window.location.href = nextLessonLink; + return; + } + + completeLesson.mutate( + { + chapterId, + lessonId, + }, + { + onSuccess: () => { + if (isCurrentLessonLast) { + return; + } + + window.location.href = nextLessonLink; + }, + }, + ); + }; + return ( -
-
- - - {children} -
- -
-
- - - + <> + {showNextWarning && ( + { + setShowNextWarning(false); + handleCompleteLesson(); + }} + onClose={() => setShowNextWarning(false)} + /> + )} + +
+
+ + + {children}
-
-
+ +
+
+ + + +
+
+ + ); } diff --git a/src/components/Course/CourseSidebar.tsx b/src/components/Course/CourseSidebar.tsx index 95cb199e9..45e9247fc 100644 --- a/src/components/Course/CourseSidebar.tsx +++ b/src/components/Course/CourseSidebar.tsx @@ -33,7 +33,12 @@ export function CourseSidebar(props: CourseSidebarProps) {
{completedPercentage}% Completed -
+
+
+
diff --git a/src/components/Course/NextLessonAlertModal.tsx b/src/components/Course/NextLessonAlertModal.tsx new file mode 100644 index 000000000..5e5ea2b3e --- /dev/null +++ b/src/components/Course/NextLessonAlertModal.tsx @@ -0,0 +1,37 @@ +import { Modal } from '../Modal'; + +type NextLessonAlertModalProps = { + onClose: () => void; + onContinue: () => void; +}; + +export function NextLessonAlertModal(props: NextLessonAlertModalProps) { + const { onClose, onContinue } = props; + + return ( + +

Warning

+

+ Please submit your answer before moving to the next lesson. +

+ +
+ + +
+
+ ); +} diff --git a/src/components/Course/QuizView.tsx b/src/components/Course/QuizView.tsx index aae613c0c..6f89cb397 100644 --- a/src/components/Course/QuizView.tsx +++ b/src/components/Course/QuizView.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Circle, CircleCheck, CircleX } from 'lucide-react'; import { cn } from '../../lib/classname'; import type { LessonFileType } from '../../lib/course'; +import { lessonSubmitStatus } from '../../stores/course'; type QuizViewProps = { lesson: LessonFileType; @@ -79,6 +80,7 @@ export function QuizView(props: QuizViewProps) { disabled={isSubmitted || !isAllAnswered} onClick={() => { setIsSubmitted(true); + lessonSubmitStatus.set('submitted'); }} > Submit my Answers @@ -91,10 +93,6 @@ export function QuizView(props: QuizViewProps) { You got {correctAnswerCount} out of {questions.length} questions right - -
- Move to Next Lesson -
)}
diff --git a/src/components/SqlCodeEditor/SqlCodeEditor.tsx b/src/components/SqlCodeEditor/SqlCodeEditor.tsx index f1fbfc5ee..02c9d61c0 100644 --- a/src/components/SqlCodeEditor/SqlCodeEditor.tsx +++ b/src/components/SqlCodeEditor/SqlCodeEditor.tsx @@ -10,29 +10,23 @@ import { useSqlEditor } from './use-sql-editor'; import { sql } from '@codemirror/lang-sql'; import { Prec } from '@codemirror/state'; import { keymap } from '@codemirror/view'; -import { type LucideIcon, Play, WandSparkles } from 'lucide-react'; +import { Check, type LucideIcon, Play, WandSparkles, X } from 'lucide-react'; import { useSqlite } from './use-sqlite'; import { cn } from '../../lib/classname'; +import { lessonSubmitStatus } from '../../stores/course'; export type SqlCodeEditorProps = { defaultValue?: string; initSteps?: string[]; expectedResults?: QueryExecResult[]; - - onQuerySubmit?: () => void; }; export function SqlCodeEditor(props: SqlCodeEditorProps) { - const { - defaultValue, - initSteps = [], - expectedResults, - onQuerySubmit, - } = props; + const { defaultValue, initSteps = [], expectedResults } = props; const editorRef = useRef(null); - const [queryResult, setQueryResult] = useState( + const [queryResults, setQueryResults] = useState( null, ); const [queryError, setQueryError] = useState(); @@ -108,6 +102,21 @@ export function SqlCodeEditor(props: SqlCodeEditorProps) { } }; + const isCorrectAnswer = + queryResults && + expectedResults && + queryResults.every((result, index) => { + const expected = expectedResults[index]; + return ( + result.columns.length === expected.columns.length && + result.values.length === expected.values.length && + result.columns.every((column, i) => column === expected.columns[i]) && + result.values.every((row, i) => + row.every((cell, j) => cell === expected.values[i][j]), + ) + ); + }); + return ( @@ -120,59 +129,69 @@ export function SqlCodeEditor(props: SqlCodeEditorProps) { >
-
- { - const query = editor?.state?.doc.toString(); - if (!query) { - return; - } - - const formatted = await formatQuery(query); - editor?.dispatch({ - changes: { - from: 0, - to: editor?.state?.doc.length, - insert: formatted, - }, - }); - }} - /> - - { - const query = editor?.state?.doc.toString(); - if (!query) { - return; - } - - const { results, error } = handleQuery(query); - setQueryResult(results); - setQueryError(error); - setIsSubmitted(true); - - onQuerySubmit?.(); - }} - /> +
+ {isSubmitted && isCorrectAnswer && ( +
+ + Correct +
+ )} + + {isSubmitted && !isCorrectAnswer && ( +
+ + Incorrect +
+ )} + +
+ { + const query = editor?.state?.doc.toString(); + if (!query) { + return; + } + + const formatted = await formatQuery(query); + editor?.dispatch({ + changes: { + from: 0, + to: editor?.state?.doc.length, + insert: formatted, + }, + }); + }} + /> + + { + const query = editor?.state?.doc.toString(); + if (!query) { + return; + } + + const { results, error } = handleQuery(query); + setQueryResults(results); + setQueryError(error); + setIsSubmitted(true); + lessonSubmitStatus.set(error ? 'wrong' : 'submitted'); + }} + /> +
- { - setQueryResult(null); - setQueryError(undefined); - setIsSubmitted(false); - }} - /> + ); diff --git a/src/components/SqlCodeEditor/SqlTableResult.tsx b/src/components/SqlCodeEditor/SqlTableResult.tsx index a43e040b6..ead48274c 100644 --- a/src/components/SqlCodeEditor/SqlTableResult.tsx +++ b/src/components/SqlCodeEditor/SqlTableResult.tsx @@ -4,57 +4,14 @@ import type { QueryExecResult } from 'sql.js'; type SqlTableResultProps = { results: QueryExecResult[] | null; error?: string; - - onTryAgain?: () => void; - - matchAnswers?: boolean; - expectedResults?: QueryExecResult[] | null; }; export function SqlTableResult(props: SqlTableResultProps) { - const { - results, - error, - onTryAgain, - expectedResults, - matchAnswers = false, - } = props; - - const isCorrectAnswer = - results && - expectedResults && - results.length === expectedResults.length && - results.every((result, index) => { - const expected = expectedResults[index]; - return ( - result.columns.length === expected.columns.length && - result.values.length === expected.values.length && - result.columns.every((column, i) => column === expected.columns[i]) && - result.values.every((row, i) => - row.every((cell, j) => cell === expected.values[i][j]), - ) - ); - }); + const { results, error } = props; return (
- {!isCorrectAnswer && results && expectedResults && matchAnswers && ( -
-

- Wrong answer! Do you want to try again? -

-
- -
-
- )} - {error && !results && (
diff --git a/src/hooks/use-course.ts b/src/hooks/use-course.ts new file mode 100644 index 000000000..b5fcf1dfb --- /dev/null +++ b/src/hooks/use-course.ts @@ -0,0 +1,52 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { queryClient } from '../stores/query-client'; +import { isLoggedIn } from '../lib/jwt'; +import { httpGet, httpPost } from '../lib/query-http'; + +export interface CourseProgressDocument { + _id: string; + userId: string; + courseId: string; + completed: { + chapterId: string; + lessonId: string; + completedAt: Date; + }[]; + createdAt: Date; + updatedAt: Date; +} + +export type CourseProgressResponse = { + completed: CourseProgressDocument['completed']; +}; + +export function useCourseProgress(courseId: string) { + return useQuery( + { + queryKey: ['course-progress', courseId], + queryFn: async () => { + return httpGet( + `/v1-course-progress/${courseId}`, + ); + }, + enabled: !!courseId && isLoggedIn(), + }, + queryClient, + ); +} + +export function useCompleteLessonMutation(courseId: string) { + return useMutation( + { + mutationFn: async (data: { chapterId: string; lessonId: string }) => { + return httpPost(`/v1-complete-lesson/${courseId}`, data); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ['course-progress', courseId], + }); + }, + }, + queryClient, + ); +} diff --git a/src/lib/query-http.ts b/src/lib/query-http.ts new file mode 100644 index 000000000..64ba4db7c --- /dev/null +++ b/src/lib/query-http.ts @@ -0,0 +1,146 @@ +import Cookies from 'js-cookie'; +import fp from '@fingerprintjs/fingerprintjs'; +import { TOKEN_COOKIE_NAME, removeAuthToken } from './jwt.ts'; + +type HttpOptionsType = RequestInit; + +type AppResponse = Record; + +export interface FetchError extends Error { + status: number; + message: string; +} + +type AppError = { + status: number; + message: string; + errors?: { message: string; location: string }[]; +}; + +type ApiReturn = ResponseType; + +/** + * Wrapper around fetch to make it easy to handle errors + * + * @param url + * @param options + */ +export async function httpCall( + url: string, + options?: HttpOptionsType, +): Promise> { + const fullUrl = url.startsWith('http') + ? url + : `${import.meta.env.PUBLIC_API_URL}${url}`; + try { + const fingerprintPromise = await fp.load(); + const fingerprint = await fingerprintPromise.get(); + + const isMultiPartFormData = options?.body instanceof FormData; + + const headers = new Headers({ + Accept: 'application/json', + Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`, + fp: fingerprint.visitorId, + ...(options?.headers ?? {}), + }); + + if (!isMultiPartFormData) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(fullUrl, { + credentials: 'include', + ...options, + headers, + }); + + // @ts-ignore + const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html'; + + const data = doesAcceptHtml ? await response.text() : await response.json(); + + // Logout user if token is invalid + if (data?.status === 401) { + removeAuthToken(); + window.location.href = '/login'; + return null as unknown as ApiReturn; + } + + if (!response.ok) { + if (data.errors) { + const error = new Error() as FetchError; + error.message = data.message; + error.status = response?.status; + throw error; + } else { + throw new Error('An unexpected error occurred'); + } + } + + return data as ResponseType; + } catch (error: any) { + throw error; + } +} + +export async function httpPost( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'POST', + body: body instanceof FormData ? body : JSON.stringify(body), + }); +} + +export async function httpGet( + url: string, + queryParams?: Record, + options?: HttpOptionsType, +): Promise> { + const searchParams = new URLSearchParams(queryParams).toString(); + const queryUrl = searchParams ? `${url}?${searchParams}` : url; + + return httpCall(queryUrl, { + credentials: 'include', + method: 'GET', + ...options, + }); +} + +export async function httpPatch( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export async function httpPut( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); +} + +export async function httpDelete( + url: string, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'DELETE', + }); +} diff --git a/src/stores/course.ts b/src/stores/course.ts new file mode 100644 index 000000000..5d3572007 --- /dev/null +++ b/src/stores/course.ts @@ -0,0 +1,4 @@ +import { atom } from 'nanostores'; + +export type LessonSubmitStatus = 'idle' | 'submitting' | 'submitted' | 'wrong'; +export const lessonSubmitStatus = atom('idle');