From 3251b08bc251efcbbb81c191986f00ce093c4dbe Mon Sep 17 00:00:00 2001 From: Christine Belzie Date: Thu, 23 Oct 2025 19:51:36 -0400 Subject: [PATCH] feat: Add course progress tracking component - Implement CourseProgress component with progress bar - Add ChapterCard for individual chapter tracking - Include ProgressBar component for visual feedback - Add styles for all components - Set up localStorage for persisting progress - Export components for use in documentation Signed-off-by: Christine Belzie --- src/components/CourseProgress/ChapterCard.jsx | 80 ++++++++ src/components/CourseProgress/ProgressBar.jsx | 21 ++ src/components/CourseProgress/index.jsx | 112 +++++++++++ .../CourseProgress/styles.module.css | 186 ++++++++++++++++++ src/components/index.js | 2 + src/utils/progress.js | 89 +++++++++ 6 files changed, 490 insertions(+) create mode 100644 src/components/CourseProgress/ChapterCard.jsx create mode 100644 src/components/CourseProgress/ProgressBar.jsx create mode 100644 src/components/CourseProgress/index.jsx create mode 100644 src/components/CourseProgress/styles.module.css create mode 100644 src/utils/progress.js diff --git a/src/components/CourseProgress/ChapterCard.jsx b/src/components/CourseProgress/ChapterCard.jsx new file mode 100644 index 00000000..9ca581d1 --- /dev/null +++ b/src/components/CourseProgress/ChapterCard.jsx @@ -0,0 +1,80 @@ +import React, { useCallback } from 'react'; +import { useLocation } from '@docusaurus/router'; +import styles from './styles.module.css'; + +const ChapterCard = ({ + id, + title, + status, + progress, + timeEstimate, + onStart, + path, + isCurrent +}) => { + const isCompleted = status === 'completed'; + const isInProgress = status === 'in-progress'; + const location = useLocation(); + + const handleAction = useCallback((e) => { + e.preventDefault(); + if (!isCompleted && !isInProgress) { + onStart?.(); + } + // If the chapter has a path, let the link handle navigation + // The progress update will happen via the URL change detection + }, [isCompleted, isInProgress, onStart]); + + return ( +
+
+ Chapter {id} + {isCompleted ? ( + + Completed + + ) : isInProgress ? ( + + In Progress + + ) : null} +
+ +

{title}

+ +
+
+
+ +
+ ⏱️ {timeEstimate} + {path ? ( + + {isCompleted ? 'Review' : isInProgress ? 'Continue' : 'Start'} + + ) : ( + + )} +
+
+ ); +}; + +export default ChapterCard; diff --git a/src/components/CourseProgress/ProgressBar.jsx b/src/components/CourseProgress/ProgressBar.jsx new file mode 100644 index 00000000..156a62c2 --- /dev/null +++ b/src/components/CourseProgress/ProgressBar.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './styles.module.css'; + +const ProgressBar = ({ percentage }) => { + return ( +
+
+ {percentage}% Complete +
+
+ ); +}; + +export default ProgressBar; diff --git a/src/components/CourseProgress/index.jsx b/src/components/CourseProgress/index.jsx new file mode 100644 index 00000000..b4feb2cd --- /dev/null +++ b/src/components/CourseProgress/index.jsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react'; +import { useLocation } from '@docusaurus/router'; +import styles from './styles.module.css'; +import ProgressBar from './ProgressBar'; +import ChapterCard from './ChapterCard'; + +// Helper function to get/set progress from localStorage +const getStoredProgress = (courseId) => { + if (typeof window === 'undefined') return {}; + const stored = localStorage.getItem(`course-progress-${courseId}`); + return stored ? JSON.parse(stored) : {}; +}; + +const saveProgress = (courseId, progress) => { + if (typeof window !== 'undefined') { + localStorage.setItem(`course-progress-${courseId}`, JSON.stringify(progress)); + } +}; + +const CourseProgress = ({ courseId, chapters: initialChapters }) => { + const location = useLocation(); + const [chapters, setChapters] = useState(initialChapters); + + // Initialize progress from localStorage + useEffect(() => { + const storedProgress = getStoredProgress(courseId); + + setChapters(prevChapters => + prevChapters.map(chapter => ({ + ...chapter, + status: storedProgress[chapter.id]?.status || 'not-started', + progress: storedProgress[chapter.id]?.progress || 0 + })) + ); + }, [courseId]); + + // Update chapter status and save to localStorage + const updateChapterStatus = (chapterId, status, progress) => { + setChapters(prevChapters => { + const updatedChapters = prevChapters.map(chapter => + chapter.id === chapterId + ? { ...chapter, status, progress } + : chapter + ); + + // Save to localStorage + const progressToSave = {}; + updatedChapters.forEach(chapter => { + progressToSave[chapter.id] = { + status: chapter.status, + progress: chapter.progress + }; + }); + + saveProgress(courseId, progressToSave); + + return updatedChapters; + }); + }; + + // Mark chapter as started/continued + const handleChapterStart = (chapterId) => { + updateChapterStatus(chapterId, 'in-progress', 10); // Start with 10% progress + // You can add navigation logic here + }; + + // Calculate overall progress + const totalChapters = chapters.length; + const completedChapters = chapters.filter(chapter => chapter.status === 'completed').length; + const inProgressChapters = chapters.filter(chapter => chapter.status === 'in-progress').length; + const overallProgress = Math.round((completedChapters / totalChapters) * 100); + + // Auto-detect current chapter based on URL + useEffect(() => { + if (!chapters.length) return; + + // Get the current path and find the matching chapter + const currentPath = location.pathname; + const currentChapter = chapters.find(chapter => + chapter.path && currentPath.includes(chapter.path) + ); + + if (currentChapter && currentChapter.status === 'not-started') { + updateChapterStatus(currentChapter.id, 'in-progress', 10); + } + }, [location.pathname, chapters]); + + return ( +
+

Course Progress

+ + +
+ {chapters.map((chapter) => ( + handleChapterStart(chapter.id)} + path={chapter.path} + isCurrent={location.pathname.includes(chapter.path || '')} + /> + ))} +
+
+ ); +}; + +export default CourseProgress; diff --git a/src/components/CourseProgress/styles.module.css b/src/components/CourseProgress/styles.module.css new file mode 100644 index 00000000..64c5ec09 --- /dev/null +++ b/src/components/CourseProgress/styles.module.css @@ -0,0 +1,186 @@ +.courseProgress { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1rem; +} + +/* Progress Bar Styles */ +.progressBarContainer { + width: 100%; + height: 24px; + background-color: #f0f0f0; + border-radius: 12px; + margin: 1rem 0 2rem; + overflow: hidden; +} + +.progressBar { + height: 100%; + background-color: #00b4d8; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 10px; + color: white; + font-size: 0.8rem; + font-weight: 500; + transition: width 0.3s ease; +} + +.progressText { + color: white; + font-size: 0.8rem; + font-weight: 500; +} + +/* Chapter List Styles */ +.chapterList { + display: grid; + gap: 1.5rem; + margin-top: 2rem; +} + +/* Chapter Card Styles */ +.chapterCard { + position: relative; + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.chapterCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.chapterCard.currentChapter { + border-left: 4px solid #00b4d8; + background-color: #f8fafc; +} + +.chapterCard.currentChapter .chapterTitle { + color: #00b4d8; +} + +.chapterHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.chapterNumber { + font-size: 0.9rem; + color: #666; + font-weight: 500; +} + +.statusBadge { + background-color: #e6f7ee; + color: #10b981; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; +} + +.inProgressBadge { + background-color: #e0f2fe; + color: #0ea5e9; +} + +.checkmark { + font-weight: bold; +} + +.chapterTitle { + margin: 0.5rem 0 1.5rem; + color: #1a1a1a; + font-size: 1.25rem; +} + +.progressContainer { + width: 100%; + height: 6px; + background-color: #f0f0f0; + border-radius: 3px; + margin: 1rem 0; + overflow: hidden; +} + +.chapterProgress { + height: 100%; + background-color: #00b4d8; + transition: width 0.3s ease; +} + +.chapterFooter { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; +} + +.timeEstimate { + font-size: 0.9rem; + color: #666; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.actionButton { + background-color: #00b4d8; + color: white; + border: none; + padding: 0.5rem 1.25rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; + text-align: center; + font-size: 0.9rem; +} + +.actionButton:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.actionButton:not(:disabled):hover { + background-color: #0096c7; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.completedButton { + background-color: #e6f7ee; + color: #10b981; +} + +.completedButton:hover { + background-color: #d1fae5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .chapterCard { + padding: 1.25rem; + } + + .chapterTitle { + font-size: 1.1rem; + } + + .actionButton { + padding: 0.4rem 1rem; + font-size: 0.9rem; + } +} diff --git a/src/components/index.js b/src/components/index.js index acc76219..0318bb4c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,3 +1,5 @@ +// Re-export components for easier imports +export { default as CourseProgress } from './CourseProgress'; import clsx from 'clsx'; import Heading from '@theme/Heading'; import styles from './styles.module.css'; diff --git a/src/utils/progress.js b/src/utils/progress.js new file mode 100644 index 00000000..6a92c282 --- /dev/null +++ b/src/utils/progress.js @@ -0,0 +1,89 @@ +// Helper function to get progress from localStorage +export const getStoredProgress = (courseId) => { + if (typeof window === 'undefined') return {}; + try { + const stored = localStorage.getItem(`course-progress-${courseId}`); + return stored ? JSON.parse(stored) : {}; + } catch (error) { + console.error('Error reading progress from localStorage:', error); + return {}; + } +}; + +// Helper function to save progress to localStorage +export const saveProgress = (courseId, progress) => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(`course-progress-${courseId}`, JSON.stringify(progress)); + } catch (error) { + console.error('Error saving progress to localStorage:', error); + } + } +}; + +// Calculate overall course progress +export const calculateCourseProgress = (chapters) => { + if (!chapters?.length) return 0; + + const totalChapters = chapters.length; + const completedChapters = chapters.filter(chapter => chapter.status === 'completed').length; + + return Math.round((completedChapters / totalChapters) * 100); +}; + +// Mark a chapter as completed +export const markChapterComplete = (courseId, chapterId, chapters) => { + const updatedChapters = chapters.map(chapter => { + if (chapter.id === chapterId) { + return { + ...chapter, + status: 'completed', + progress: 100, + completedAt: new Date().toISOString() + }; + } + return chapter; + }); + + // Save to localStorage + const progressToSave = {}; + updatedChapters.forEach(chapter => { + progressToSave[chapter.id] = { + status: chapter.status, + progress: chapter.progress, + completedAt: chapter.completedAt + }; + }); + + saveProgress(courseId, progressToSave); + return updatedChapters; +}; + +// Get the next chapter to continue +// Helper function to get the next chapter to continue +export const getNextChapter = (currentPath, chapters) => { + if (!chapters?.length) return null; + + // Find the current chapter index + const currentIndex = chapters.findIndex(chapter => + chapter.path && currentPath.includes(chapter.path) + ); + + // If current chapter not found or it's the last one, return null + if (currentIndex === -1 || currentIndex === chapters.length - 1) { + return null; + } + + // Return the next chapter + return chapters[currentIndex + 1]; +}; + +// Get chapter by ID +export const getChapterById = (chapters, chapterId) => { + return chapters.find(chapter => chapter.id === chapterId); +}; + +// Get chapter by path +export const getChapterByPath = (chapters, path) => { + return chapters.find(chapter => chapter.path && path.includes(chapter.path)); +};