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 (
-
)}
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');