feat/course
Arik Chakma 1 month ago
parent 8502b302b2
commit a831810487
  1. 60
      src/components/Course/ChallengeView.tsx
  2. 87
      src/components/Course/Chapter.tsx
  3. 8
      src/components/Course/CourseLayout.tsx
  4. 20
      src/components/Course/CourseSidebar.tsx
  5. 29
      src/components/Course/LessonView.tsx
  6. 99
      src/components/Course/QuizView.tsx
  7. 4
      src/data/courses/sql/chapters/introduction/lessons/challenge-1.md
  8. 67
      src/data/courses/sql/chapters/introduction/lessons/quiz-1.md
  9. 2
      src/layouts/SkeletonLayout.astro
  10. 10
      src/lib/course.ts
  11. 56
      src/pages/courses/[courseId]/[chapterId]/[lessonId].astro
  12. 27
      src/pages/learn-sql/index.astro

@ -3,36 +3,46 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from '../Resizable';
import { CourseSidebar, type CourseSidebarProps } from './CourseSidebar';
import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import {
SqlCodeEditor,
type SqlCodeEditorProps,
} from '../SqlCodeEditor/SqlCodeEditor';
import { SqlCodeEditor } from '../SqlCodeEditor/SqlCodeEditor';
import type { ReactNode } from 'react';
import type {
ChapterFileType,
CourseFileType,
LessonFileType,
} from '../../lib/course';
type ChallengeViewProps = {
courseId: string;
chapterId: string;
lessonId: string;
type ChallengeViewProps = SqlCodeEditorProps &
CourseSidebarProps & {
children: ReactNode;
title: string;
course: CourseFileType & {
chapters: ChapterFileType[];
};
lesson: LessonFileType;
children: ReactNode;
};
export function ChallengeView(props: ChallengeViewProps) {
const {
children,
title,
chapters,
completedPercentage,
...sqlCodeEditorProps
} = props;
const { children, title, course, lesson, courseId, lessonId, chapterId } =
props;
const { chapters } = course;
return (
<CourseLayout>
<CourseSidebar
title={title}
chapters={chapters}
completedPercentage={completedPercentage}
/>
const { frontmatter } = lesson;
const { defaultValue, initSteps, expectedResults } = frontmatter;
return (
<CourseLayout
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={title}
chapters={chapters}
completedPercentage={0}
>
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={60} minSize={20}>
<div className="relative h-full">
@ -45,7 +55,11 @@ export function ChallengeView(props: ChallengeViewProps) {
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={40} minSize={20}>
<SqlCodeEditor {...sqlCodeEditorProps} />
<SqlCodeEditor
defaultValue={defaultValue}
initSteps={initSteps}
expectedResults={expectedResults}
/>
</ResizablePanel>
</ResizablePanelGroup>
</CourseLayout>

@ -1,17 +1,16 @@
import { Check } from 'lucide-react';
import { cn } from '../../lib/classname';
import type {
ChallengeFileType,
ChapterFileType,
LessonFileType,
QuizFileType,
} from '../../lib/course';
import type { ChapterFileType, LessonFileType } from '../../lib/course';
import { useMemo } from 'react';
type ChapterProps = ChapterFileType & {
index: number;
isActive?: boolean;
isCompleted?: boolean;
courseId: string;
chapterId: string;
lessonId?: string;
onChapterClick?: () => void;
};
@ -20,12 +19,35 @@ export function Chapter(props: ChapterProps) {
index,
frontmatter,
lessons,
exercises,
isActive = false,
onChapterClick,
courseId,
chapterId,
lessonId,
} = 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 filteredLessons = useMemo(
() =>
lessons
?.filter((lesson) => lesson.frontmatter.type === 'lesson')
?.sort((a, b) => a.frontmatter.order - b.frontmatter.order) || [],
[lessons],
);
return (
<div>
<button
@ -44,9 +66,20 @@ export function Chapter(props: ChapterProps) {
{isActive && (
<div className="flex flex-col border-b border-zinc-800">
<div>
{lessons.map((lesson) => (
<Lesson key={lesson.id} {...lesson} isCompleted={false} />
))}
{filteredLessons?.map((lesson) => {
const isActive = lessonId === lesson.id;
return (
<Lesson
key={lesson.id}
{...lesson}
courseId={courseId}
chapterId={chapterId}
isActive={isActive}
isCompleted={false}
/>
);
})}
</div>
<div className="relative">
@ -58,9 +91,20 @@ export function Chapter(props: ChapterProps) {
</div>
<div>
{exercises.map((exercise) => (
<Lesson key={exercise.id} {...exercise} isCompleted={false} />
))}
{exercises?.map((exercise) => {
const isActive = lessonId === exercise.id;
return (
<Lesson
key={exercise.id}
{...exercise}
courseId={courseId}
chapterId={chapterId}
isActive={isActive}
isCompleted={false}
/>
);
})}
</div>
</div>
)}
@ -68,21 +112,34 @@ export function Chapter(props: ChapterProps) {
);
}
type LessonProps = (LessonFileType | QuizFileType | ChallengeFileType) & {
type LessonProps = LessonFileType & {
courseId: string;
chapterId: string;
isActive?: boolean;
isCompleted?: boolean;
};
export function Lesson(props: LessonProps) {
const { frontmatter, isCompleted, isActive } = props;
const {
frontmatter,
isCompleted,
isActive,
courseId,
chapterId,
id: lessonId,
} = props;
const { title } = frontmatter;
const href = `/courses/${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',
)}
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" />}

@ -1,12 +1,16 @@
import { CourseSidebar, type CourseSidebarProps } from './CourseSidebar';
type CourseLayoutProps = {
children: React.ReactNode;
};
} & CourseSidebarProps;
export function CourseLayout(props: CourseLayoutProps) {
const { children } = props;
const { children, ...sidebarProps } = props;
return (
<section className="grid h-screen grid-cols-[240px_1fr] overflow-hidden bg-zinc-900 text-zinc-50">
<CourseSidebar {...sidebarProps} />
{children}
</section>
);

@ -3,6 +3,10 @@ import type { ChapterFileType } from '../../lib/course';
import { Chapter } from './Chapter';
export type CourseSidebarProps = {
courseId: string;
chapterId: string;
lessonId: string;
title: string;
chapters: ChapterFileType[];
@ -10,9 +14,16 @@ export type CourseSidebarProps = {
};
export function CourseSidebar(props: CourseSidebarProps) {
const { title, chapters, completedPercentage } = props;
const {
title,
chapters,
completedPercentage,
chapterId,
lessonId,
courseId,
} = props;
const [activeChapterId, setActiveChapterId] = useState('');
const [activeChapterId, setActiveChapterId] = useState(chapterId);
return (
<aside className="border-r border-zinc-800">
@ -27,7 +38,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) => {
{chapters?.map((chapter, index) => {
const isActive = activeChapterId === chapter.id;
return (
@ -43,6 +54,9 @@ export function CourseSidebar(props: CourseSidebarProps) {
}}
index={index + 1}
{...chapter}
courseId={courseId}
chapterId={chapterId}
lessonId={lessonId}
/>
);
})}

@ -3,18 +3,39 @@ import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import { Circle, CircleCheck, CircleX } from 'lucide-react';
import { cn } from '../../lib/classname';
import type {
ChapterFileType,
CourseFileType,
LessonFileType,
} from '../../lib/course';
type LessonViewProps = {
courseId: string;
chapterId: string;
lessonId: string;
title: string;
course: CourseFileType & {
chapters: ChapterFileType[];
};
lesson: LessonFileType;
children: ReactNode;
};
export function LessonView(props: LessonViewProps) {
const { children } = props;
const { children, title, course, lesson, courseId, lessonId, chapterId } =
props;
const { chapters } = course;
return (
<CourseLayout>
<CourseSidebar />
<CourseLayout
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={title}
chapters={chapters}
completedPercentage={0}
>
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="mx-auto max-w-xl p-4 py-10">{children}</div>

@ -3,69 +3,31 @@ import { CourseSidebar } from './CourseSidebar';
import { CourseLayout } from './CourseLayout';
import { Circle, CircleCheck, CircleX } from 'lucide-react';
import { cn } from '../../lib/classname';
import type {
ChapterFileType,
CourseFileType,
LessonFileType,
} from '../../lib/course';
type QuizViewProps = {
courseId: string;
chapterId: string;
lessonId: string;
title: string;
course: CourseFileType & {
chapters: ChapterFileType[];
};
lesson: LessonFileType;
};
export function QuizView(props: QuizViewProps) {
const { title, course, lesson, courseId, lessonId, chapterId } = props;
const { chapters } = course;
const { frontmatter } = lesson;
const { questions = [] } = frontmatter;
const questions = [
{
id: 1,
title:
'Which of the following SQL clauses is used to filter results after the GROUP BY clause?',
options: [
{ id: 1, text: 'WHERE' },
{ id: 2, text: 'HAVING', isCorrectOption: true },
{ id: 3, text: 'GROUP BY' },
{ id: 4, text: 'ORDER BY' },
],
},
{
id: 2,
title:
'Which SQL function is used to return the first non-null expression?',
options: [
{ id: 1, text: 'COALESCE', isCorrectOption: true },
{ id: 2, text: 'IFNULL' },
{ id: 3, text: 'NULLIF' },
{ id: 4, text: 'NVL' },
],
},
{
id: 3,
title: 'What is the purpose of an SQL CTE (Common Table Expression)?',
options: [
{
id: 1,
text: 'To create temporary tables that last for the duration of a query',
isCorrectOption: true,
},
{ id: 2, text: 'To define reusable views' },
{ id: 3, text: 'To encapsulate subqueries' },
{ id: 4, text: 'To optimize the execution of queries' },
],
},
{
id: 4,
title:
'In an SQL window function, which clause defines the subset of rows to apply the function on?',
options: [
{ id: 1, text: 'ORDER BY' },
{ id: 2, text: 'PARTITION BY', isCorrectOption: true },
{ id: 3, text: 'GROUP BY' },
{ id: 4, text: 'DISTINCT' },
],
},
{
id: 5,
title:
'Which SQL join returns all rows when there is a match in either of the tables?',
options: [
{ id: 1, text: 'INNER JOIN' },
{ id: 2, text: 'LEFT JOIN' },
{ id: 3, text: 'RIGHT JOIN' },
{ id: 4, text: 'FULL OUTER JOIN', isCorrectOption: true },
],
},
];
export function QuizView() {
const [selectedOptions, setSelectedOptions] = useState<
Record<number, number | undefined>
>({});
@ -84,9 +46,14 @@ export function QuizView() {
}).length;
return (
<CourseLayout>
<CourseSidebar />
<CourseLayout
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={title}
chapters={chapters}
completedPercentage={0}
>
<div className="relative h-full">
<div className="absolute inset-0 overflow-y-auto [scrollbar-color:#3f3f46_#27272a;]">
<div className="mx-auto max-w-xl p-4 py-10">
@ -101,7 +68,7 @@ export function QuizView() {
key={question.id}
id={question.id}
title={question.title}
disabled={status === 'submitted'}
disabled={isSubmitted}
options={question.options.map((option) => {
const selectedOptionId = selectedOptions?.[question.id];

@ -1,7 +1,7 @@
---
title: Challenge 1
description: Write a SQL query to find the total number of orders in the `orders` table.
order: 200
order: 300
type: challenge
defaultValue: SELECT * FROM orders;
initSteps:
@ -24,8 +24,6 @@ expectedResults:
- [5]
---
<!-- /sql/:chapterId/:(lessonId/challengeId/quizId) -->
## Instructions
Write a SQL query to find the total number of orders in the `orders` table.

@ -0,0 +1,67 @@
---
title: Quiz 1
description: Test your knowledge of SQL queries with this quiz.
order: 200
type: quiz
questions:
- id: 1
title: 'Which of the following SQL clauses is used to filter results after the GROUP BY clause?'
options:
- id: 1
text: 'WHERE'
- id: 2
text: 'HAVING'
isCorrectOption: true
- id: 3
text: 'GROUP BY'
- id: 4
text: 'ORDER BY'
- id: 2
title: 'Which SQL function is used to return the first non-null expression?'
options:
- id: 1
text: 'COALESCE'
isCorrectOption: true
- id: 2
text: 'IFNULL'
- id: 3
text: 'NULLIF'
- id: 4
text: 'NVL'
- id: 3
title: 'What is the purpose of an SQL CTE (Common Table Expression)?'
options:
- id: 1
text: 'To create temporary tables that last for the duration of a query'
isCorrectOption: true
- id: 2
text: 'To define reusable views'
- id: 3
text: 'To encapsulate subqueries'
- id: 4
text: 'To optimize the execution of queries'
- id: 4
title: 'In an SQL window function, which clause defines the subset of rows to apply the function on?'
options:
- id: 1
text: 'ORDER BY'
- id: 2
text: 'PARTITION BY'
isCorrectOption: true
- id: 3
text: 'GROUP BY'
- id: 4
text: 'DISTINCT'
- id: 5
title: 'Which SQL join returns all rows when there is a match in either of the tables?'
options:
- id: 1
text: 'INNER JOIN'
- id: 2
text: 'LEFT JOIN'
- id: 3
text: 'RIGHT JOIN'
- id: 4
text: 'FULL OUTER JOIN'
isCorrectOption: true
---

@ -1,5 +1,5 @@
---
import BaseLayout, { Props as BaseLayoutProps } from './BaseLayout.astro';
import BaseLayout, { type Props as BaseLayoutProps } from './BaseLayout.astro';
export interface Props extends BaseLayoutProps {}

@ -18,6 +18,16 @@ export type LessonFrontmatter = {
columns: string[];
values: string[][];
}[];
questions?: {
id: number;
title: string;
options: {
id: number;
text: string;
isCorrectOption?: boolean;
}[];
}[];
};
export type LessonFileType = MarkdownFileType<LessonFrontmatter> & {

@ -1,4 +1,8 @@
---
import { ChallengeView } from '../../../../components/Course/ChallengeView';
import { LessonView } from '../../../../components/Course/LessonView';
import { QuizView } from '../../../../components/Course/QuizView';
import SkeletonLayout from '../../../../layouts/SkeletonLayout.astro';
import {
getAllCourses,
getChaptersByCourseId,
@ -14,7 +18,7 @@ interface Params extends Record<string, string | undefined> {
}
interface Props {
course: CourseFileType;
course: CourseFileType & { chapters: ChapterFileType[] };
chapter: ChapterFileType;
lesson: LessonFileType;
}
@ -65,6 +69,50 @@ const { courseId, chapterId } = Astro.params;
const { course, chapter, lesson } = Astro.props;
---
<pre>
{JSON.stringify(lesson, null, 2)}
</pre>
<SkeletonLayout title={course.frontmatter.title}>
{
lesson.frontmatter.type === 'challenge' && (
<ChallengeView
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={course.frontmatter.title}
course={course}
lesson={lesson}
client:load
>
<lesson.Content />
</ChallengeView>
)
}
{
lesson.frontmatter.type === 'lesson' && (
<LessonView
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={course.frontmatter.title}
course={course}
lesson={lesson}
client:load
>
<lesson.Content />
</LessonView>
)
}
{
lesson.frontmatter.type === 'quiz' && (
<QuizView
courseId={courseId}
chapterId={chapterId}
lessonId={lesson.id}
title={course.frontmatter.title}
course={course}
lesson={lesson}
client:load
></QuizView>
)
}
</SkeletonLayout>

@ -1,27 +0,0 @@
---
import { ChallengeView } from '../../components/Course/ChallengeView';
import type { ChallengeFileType } from '../../lib/course';
import { getCourseById, getCourseExerciseById } from '../../lib/course';
const course = await getCourseById('sql');
const exercise = (await getCourseExerciseById(
'sql',
'introduction',
'challenge-1',
)) as ChallengeFileType;
---
<ChallengeView
title={exercise.frontmatter.title}
chapters={course.chapters}
completedPercentage={0}
defaultValue={exercise.frontmatter.defaultValue}
expectedResults={exercise.frontmatter.expectedResults}
initSteps={exercise.frontmatter.initSteps}
client:load
>
<div class='prose prose-xl prose-invert'>
<exercise.Content />
</div>
</ChallengeView>
Loading…
Cancel
Save