feat: course progress

feat/course
Arik Chakma 1 month ago
parent 13855f06dd
commit e11ac4bf84
  1. 2
      .astro/settings.json
  2. 97
      src/components/Course/Chapter.tsx
  3. 109
      src/components/Course/CourseLayout.tsx
  4. 7
      src/components/Course/CourseSidebar.tsx
  5. 37
      src/components/Course/NextLessonAlertModal.tsx
  6. 6
      src/components/Course/QuizView.tsx
  7. 69
      src/components/SqlCodeEditor/SqlCodeEditor.tsx
  8. 45
      src/components/SqlCodeEditor/SqlTableResult.tsx
  9. 52
      src/hooks/use-course.ts
  10. 146
      src/lib/query-http.ts
  11. 4
      src/stores/course.ts

@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1728296475293
"lastUpdateCheck": 1729575132842
}
}

@ -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,
new Set(
(courseProgress?.completed || [])
.filter((l) => l.chapterId === chapterId)
.map((l) => `${l.chapterId}/${l.lessonId}`),
),
)
?.sort((a, b) => a.frontmatter.order - b.frontmatter.order) || [],
[lessons],
[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 (
<div>
<button
className={cn(
'flex w-full items-center gap-2 border-b border-zinc-800 p-2 text-sm',
isActive && 'bg-zinc-300 text-zinc-900',
'relative z-10 flex w-full items-center gap-2 border-b border-zinc-800 p-2 text-sm',
isActive && 'text-white',
)}
onClick={onChapterClick}
>
@ -65,6 +83,18 @@ export function Chapter(props: ChapterProps) {
{index}
</div>
<span className="truncate text-left">{title}</span>
{isChapterCompleted && lessons.length > 0 && (
<CheckIcon additionalClasses="h-4 w-4 ml-auto" />
)}
<div
className="absolute inset-0 -z-10 w-[var(--completed-percentage)] bg-zinc-800 transition-[width] duration-150 will-change-[width]"
style={
{
'--completed-percentage': `${completedPercentage}%`,
} as CSSProperties
}
/>
</button>
{isActive && (
@ -74,6 +104,9 @@ export function Chapter(props: ChapterProps) {
<div>
{filteredLessons?.map((lesson) => {
const isActive = lessonId === lesson.id;
const isCompleted = completeLessonSet.has(
`${chapterId}/${lesson.id}`,
);
return (
<Lesson
@ -82,7 +115,7 @@ export function Chapter(props: ChapterProps) {
courseId={courseId}
chapterId={chapterId}
isActive={isActive}
isCompleted={false}
isCompleted={isCompleted}
/>
);
})}
@ -99,6 +132,9 @@ export function Chapter(props: ChapterProps) {
<div>
{exercises?.map((exercise) => {
const isActive = lessonId === exercise.id;
const isCompleted = completeLessonSet.has(
`${chapterId}/${exercise.id}`,
);
return (
<Lesson
@ -107,7 +143,7 @@ export function Chapter(props: ChapterProps) {
courseId={courseId}
chapterId={chapterId}
isActive={isActive}
isCompleted={false}
isCompleted={isCompleted}
/>
);
})}
@ -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 (
<a
className={cn(
'relative flex w-full items-center gap-2 p-2 text-sm text-zinc-600',
isActive && 'bg-zinc-800 text-white',
isActive && 'bg-zinc-800/50 text-white',
)}
href={href}
>
<div className="relative z-10 flex size-5 items-center justify-center rounded-full bg-zinc-700 text-xs text-white">
{isCompleted && <Check className="h-4 w-4" />}
{isCompleted && <Check className="h-3 w-3 stroke-[3]" />}
{isLoading && isMounted && (
<Loader2 className="h-3 w-3 animate-spin stroke-[3] opacity-60" />
)}
</div>
<span className="truncate text-left">{title}</span>

@ -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,16 +45,64 @@ 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 (
<>
{showNextWarning && (
<NextLessonAlertModal
onContinue={() => {
setShowNextWarning(false);
handleCompleteLesson();
}}
onClose={() => setShowNextWarning(false)}
/>
)}
<section className="grid h-screen grid-rows-[1fr_60px] overflow-hidden bg-zinc-900 text-zinc-50">
<div className="grid grid-cols-[240px_1fr] overflow-hidden">
<CourseSidebar {...sidebarProps} />
<CourseSidebar
{...sidebarProps}
completedPercentage={Number(courseProgressPercentage)}
/>
{children}
</div>
@ -43,7 +114,7 @@ export function CourseLayout(props: CourseLayoutProps) {
onClick={() => {
window.location.href = prevLessonLink;
}}
disabled={!prevLessonLink}
disabled={!prevLessonLink || completeLesson.isPending}
>
<ChevronLeft className="size-4 stroke-[3]" />
Prev
@ -52,23 +123,29 @@ export function CourseLayout(props: CourseLayoutProps) {
<button
className="flex items-center gap-1 rounded-lg border border-zinc-800 px-2 py-1.5 text-sm leading-none disabled:opacity-60"
onClick={() => {
if (!isSubmitted && lesson?.frontmatter?.type !== 'lesson') {
// show a warning modal
window.alert(
'Please submit your answer before moving to the next lesson.',
);
if (
$lessonSubmitStatus === 'idle' &&
lesson?.frontmatter?.type !== 'lesson' &&
!isCurrentLessonCompleted
) {
setShowNextWarning(true);
return;
}
window.location.href = nextLessonLink;
handleCompleteLesson();
}}
disabled={!nextLessonLink}
disabled={completeLesson.isPending}
>
Next
{completeLesson.isPending ? (
<Loader2 className="size-4 animate-spin stroke-[3]" />
) : (
<ChevronRight className="size-4 stroke-[3]" />
)}
</button>
</div>
</footer>
</section>
</>
);
}

@ -33,7 +33,12 @@ export function CourseSidebar(props: CourseSidebarProps) {
<div className="mt-4">
<span>{completedPercentage}% Completed</span>
<div className="mt-2 h-1 w-full bg-zinc-800"></div>
<div className="relative mt-2 h-1 w-full overflow-hidden rounded-md bg-zinc-800">
<div
className="absolute inset-0 rounded-md bg-zinc-500 transition-[width] duration-150 will-change-[width]"
style={{ width: `${completedPercentage}%` }}
/>
</div>
</div>
</div>

@ -0,0 +1,37 @@
import { Modal } from '../Modal';
type NextLessonAlertModalProps = {
onClose: () => void;
onContinue: () => void;
};
export function NextLessonAlertModal(props: NextLessonAlertModalProps) {
const { onClose, onContinue } = props;
return (
<Modal
onClose={onClose}
bodyClassName="h-auto p-4 bg-zinc-900 border border-zinc-700 text-white"
>
<h2 className="text-lg font-semibold">Warning</h2>
<p className="mt-2">
Please submit your answer before moving to the next lesson.
</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm leading-none"
onClick={onClose}
>
Cancel
</button>
<button
className="rounded-lg bg-zinc-800 px-4 py-2 text-sm leading-none text-zinc-50"
onClick={onContinue}
>
Continue
</button>
</div>
</Modal>
);
}

@ -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
</span>
<a className="disabled:cusror-not-allowed rounded-xl border border-zinc-700 bg-zinc-800 p-2 px-4 text-sm font-medium text-white focus:outline-none">
Move to Next Lesson
</a>
</div>
)}
</div>

@ -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<HTMLDivElement>(null);
const [queryResult, setQueryResult] = useState<QueryExecResult[] | null>(
const [queryResults, setQueryResults] = useState<QueryExecResult[] | null>(
null,
);
const [queryError, setQueryError] = useState<string | undefined>();
@ -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 (
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={65} className="flex flex-col">
@ -120,7 +129,27 @@ export function SqlCodeEditor(props: SqlCodeEditorProps) {
></div>
</div>
<div className="flex items-center justify-end gap-1 border-t border-zinc-800 p-2">
<div
className={cn(
'flex items-center justify-end gap-1 border-t border-zinc-800 p-2',
isSubmitted && 'justify-between',
)}
>
{isSubmitted && isCorrectAnswer && (
<div className="flex items-center gap-1 text-sm text-green-500">
<Check className="h-4 w-4" />
<span>Correct</span>
</div>
)}
{isSubmitted && !isCorrectAnswer && (
<div className="flex items-center gap-1 text-sm text-red-500">
<X className="h-4 w-4" />
<span>Incorrect</span>
</div>
)}
<div className="flex items-center gap-1">
<DatabaseActionButton
icon={WandSparkles}
onClick={async () => {
@ -149,30 +178,20 @@ export function SqlCodeEditor(props: SqlCodeEditorProps) {
}
const { results, error } = handleQuery(query);
setQueryResult(results);
setQueryResults(results);
setQueryError(error);
setIsSubmitted(true);
onQuerySubmit?.();
lessonSubmitStatus.set(error ? 'wrong' : 'submitted');
}}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={35}>
<SqlTableResult
results={queryResult}
error={queryError}
matchAnswers={isSubmitted}
expectedResults={expectedResults}
onTryAgain={() => {
setQueryResult(null);
setQueryError(undefined);
setIsSubmitted(false);
}}
/>
<SqlTableResult results={queryResults} error={queryError} />
</ResizablePanel>
</ResizablePanelGroup>
);

@ -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 (
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
{!isCorrectAnswer && results && expectedResults && matchAnswers && (
<div className="border-b border-zinc-800 p-1 py-8 text-sm">
<p className="text-balance text-center">
Wrong answer! Do you want to try again?
</p>
<div className="mt-2 flex items-center justify-center gap-2">
<button
className="rounded-md bg-zinc-800 px-2 py-0.5 outline-none focus:outline-none"
onClick={onTryAgain}
>
Yes, I want to try again
</button>
</div>
</div>
)}
{error && !results && (
<div className="mt-4 flex flex-col items-center justify-center p-2 text-center text-red-500">
<ServerCrash className="h-8 w-8" />

@ -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<CourseProgressResponse>(
`/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,
);
}

@ -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<string, any>;
export interface FetchError extends Error {
status: number;
message: string;
}
type AppError = {
status: number;
message: string;
errors?: { message: string; location: string }[];
};
type ApiReturn<ResponseType> = ResponseType;
/**
* Wrapper around fetch to make it easy to handle errors
*
* @param url
* @param options
*/
export async function httpCall<ResponseType = AppResponse>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
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<ResponseType>;
}
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<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
});
}
export async function httpGet<ResponseType = AppResponse>(
url: string,
queryParams?: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
const searchParams = new URLSearchParams(queryParams).toString();
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
return httpCall<ResponseType>(queryUrl, {
credentials: 'include',
method: 'GET',
...options,
});
}
export async function httpPatch<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function httpPut<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function httpDelete<ResponseType = AppResponse>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'DELETE',
});
}

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export type LessonSubmitStatus = 'idle' | 'submitting' | 'submitted' | 'wrong';
export const lessonSubmitStatus = atom<LessonSubmitStatus>('idle');
Loading…
Cancel
Save