/** * LessonEngine - Core class for managing lessons and applying/testing user code * Single source of truth for lesson state and progress */ import { validateUserCode } from "../helpers/validator.js"; import { marked } from "marked"; // Auth sync - lazy loaded to avoid circular dependencies let authModule = null; async function getAuthModule() { if (!authModule) { try { authModule = await import("../auth.js"); } catch (e) { // Auth module not available, skip cloud sync return null; } } return authModule; } export class LessonEngine { constructor() { this.currentLesson = null; this.userCode = ""; this.currentModule = null; this.currentLessonIndex = 0; this.lastRenderedCode = ""; // Track last applied code to prevent unnecessary re-renders this.modules = []; this.userProgress = {}; // Format: { moduleId: { completed: [0, 2, 3], current: 4 } } this.userCodeMap = new Map(); // Store user code for each lesson this.loadUserProgress(); } /** * Initialize with modules array * @param {Array} modules - Available modules */ setModules(modules) { this.modules = modules; // Clear current module/lesson to force reload with new language content this.currentModule = null; this.currentLesson = null; this.loadUserCodeFromStorage(); } /** * Set the current module * @param {Object} module - The module object from the config */ setModule(module) { this.currentModule = module; this.currentLessonIndex = 0; // Load user progress for this module if (!this.userProgress[module.id]) { this.userProgress[module.id] = { completed: [], current: 0 }; } this.currentLessonIndex = this.userProgress[module.id].current || 0; if (module && module.lessons && module.lessons.length > 0) { this.setLesson(module.lessons[this.currentLessonIndex]); } this.saveUserProgress(); } /** * Set module by ID * @param {string} moduleId - The module ID * @returns {boolean} Whether the operation was successful */ setModuleById(moduleId) { const module = this.modules.find((m) => m.id === moduleId); if (!module) return false; this.setModule(module); return true; } /** * Set the current lesson * @param {Object} lesson - The lesson object from the config */ setLesson(lesson) { this.currentLesson = lesson; // Load saved user code for this lesson or use initial code const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; this.userCode = this.userCodeMap.get(lessonKey) || lesson.initialCode || ""; this.lastRenderedCode = ""; // Reset last rendered code this.renderPreview(); } /** * Set lesson by index within the current module * @param {number} index - The lesson index * @returns {boolean} Whether the operation was successful */ setLessonByIndex(index) { if (!this.currentModule || !this.currentModule.lessons) { return false; } if (index < 0 || index >= this.currentModule.lessons.length) { return false; } this.currentLessonIndex = index; this.setLesson(this.currentModule.lessons[index]); // Update progress this.userProgress[this.currentModule.id].current = index; this.saveUserProgress(); return true; } /** * Move to the next lesson (crosses module boundaries) * @returns {boolean} Whether the operation was successful */ nextLesson() { // Try next lesson in current module if (this.setLessonByIndex(this.currentLessonIndex + 1)) { return true; } // At end of module, try next module const currentModuleIndex = this.modules.findIndex((m) => m.id === this.currentModule?.id); if (currentModuleIndex >= 0 && currentModuleIndex < this.modules.length - 1) { const nextModule = this.modules[currentModuleIndex + 1]; this.setModule(nextModule); this.setLessonByIndex(0); // Start at first lesson return true; } return false; } /** * Move to the previous lesson (crosses module boundaries) * @returns {boolean} Whether the operation was successful */ previousLesson() { // Try previous lesson in current module if (this.setLessonByIndex(this.currentLessonIndex - 1)) { return true; } // At start of module, try previous module const currentModuleIndex = this.modules.findIndex((m) => m.id === this.currentModule?.id); if (currentModuleIndex > 0) { const prevModule = this.modules[currentModuleIndex - 1]; this.setModule(prevModule); // Go to last lesson of previous module const lastIndex = prevModule.lessons.length - 1; this.setLessonByIndex(lastIndex); return true; } return false; } /** * Apply user-written CSS to the preview area * @param {string} code - User CSS code * @param {boolean} forceUpdate - Force update the preview even if code hasn't changed */ applyUserCode(code, forceUpdate = false) { if (!this.currentLesson) return; this.userCode = code; // Save user code for this lesson const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; this.userCodeMap.set(lessonKey, code); this.saveUserCodeToStorage(); // Only re-render if code changed or forced update if (forceUpdate || this.lastRenderedCode !== code) { this.lastRenderedCode = code; this.renderPreview(); } } /** * Get the complete CSS by combining all parts * @returns {string} The complete CSS */ getCompleteCss() { if (!this.currentLesson) return ""; const { codePrefix, codeSuffix } = this.currentLesson; return ` ${codePrefix || ""} ${this.userCode || ""} ${codeSuffix || ""} `; } /** * Render the preview for the current lesson */ renderPreview() { if (!this.currentLesson) return; const mode = this.currentLesson.mode || this.currentModule?.mode || "css"; const { previewHTML, previewBaseCSS, previewContainer, sandboxCSS } = this.currentLesson; const iframe = document.createElement("iframe"); iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.style.border = "none"; iframe.title = "Preview"; iframe.setAttribute("sandbox", "allow-scripts"); const container = document.getElementById(previewContainer || "preview-area"); container.innerHTML = ""; container.appendChild(iframe); let html; if (mode === "html" || mode === "playground") { // For HTML/playground mode, user code IS the HTML content (may include ${userHtml} `; } else if (mode === "tailwind") { // For Tailwind mode, user code goes directly in HTML classes const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode); html = ` ${htmlWithClasses} `; } else if (mode === "javascript") { // For JavaScript mode, user code runs as a script against previewHTML const { codePrefix, codeSuffix } = this.currentLesson; const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`; html = ` ${previewHTML || ""} `; } else if (mode === "markdown") { // For Markdown mode, parse user code to HTML const renderedHtml = marked.parse(this.userCode || ""); html = ` ${renderedHtml} `; } else { // Original CSS mode const userCssWithWrapper = this.getCompleteCss(); html = ` ${previewHTML} `; } iframe.srcdoc = html; } injectTailwindClasses(html, userClasses) { // Replace placeholder in HTML with user's Tailwind classes return html.replace(/{{USER_CLASSES}}/g, userClasses); } /** * Render the expected/solution preview for comparison */ renderExpectedPreview() { if (!this.currentLesson) return; // Use 'solution' property from lesson JSON (not 'solutionCode') const solutionCode = this.currentLesson.solution; if (!solutionCode) { // No solution code provided, hide the expected pane or show placeholder const expectedContainer = document.getElementById("preview-expected"); if (expectedContainer) { expectedContainer.innerHTML = '
No expected output available
'; } return; } const mode = this.currentLesson.mode || this.currentModule?.mode || "css"; const { previewHTML, previewBaseCSS, sandboxCSS } = this.currentLesson; const iframe = document.createElement("iframe"); iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.style.border = "none"; iframe.title = "Expected Result"; iframe.setAttribute("sandbox", "allow-scripts"); const container = document.getElementById("preview-expected"); if (!container) return; container.innerHTML = ""; container.appendChild(iframe); let html; if (mode === "html" || mode === "playground") { // For HTML/playground mode, solution code IS the HTML content html = ` ${solutionCode} `; } else if (mode === "tailwind") { // For Tailwind mode, inject solution classes into HTML const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode); html = ` ${htmlWithClasses} `; } else if (mode === "javascript") { // For JavaScript mode, solution code runs as a script against previewHTML const { codePrefix, codeSuffix } = this.currentLesson; const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`; html = ` ${previewHTML || ""} `; } else if (mode === "markdown") { // For Markdown mode, parse solution to HTML const renderedHtml = marked.parse(solutionCode || ""); html = ` ${renderedHtml} `; } else { // CSS mode - wrap solution with prefix/suffix const { codePrefix, codeSuffix } = this.currentLesson; const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`; html = ` ${previewHTML} `; } iframe.srcdoc = html; } /** * Show merge animation when student's output matches expected */ showMatchAnimation() { const overlay = document.getElementById("match-overlay"); const comparison = document.getElementById("preview-comparison"); if (overlay && comparison) { overlay.classList.add("matched"); comparison.classList.add("matched"); // Remove animation classes after delay setTimeout(() => { overlay.classList.remove("matched"); comparison.classList.remove("matched"); }, 2500); } } /** * Hide match animation */ hideMatchAnimation() { const overlay = document.getElementById("match-overlay"); const comparison = document.getElementById("preview-comparison"); if (overlay) overlay.classList.remove("matched"); if (comparison) comparison.classList.remove("matched"); } /** * Validate user code against the current lesson's requirements * @returns {Object} Validation result */ validateCode() { if (!this.currentLesson) { return { isValid: false, message: "No active lesson to validate against." }; } const result = validateUserCode(this.userCode, this.currentLesson); // Mark lesson as completed if valid if (result.isValid) { const moduleProgress = this.userProgress[this.currentModule.id]; if (!moduleProgress.completed.includes(this.currentLessonIndex)) { moduleProgress.completed.push(this.currentLessonIndex); this.saveUserProgress(); } } return result; } /** * Check if current lesson is completed * @returns {boolean} Whether the lesson is completed */ isCurrentLessonCompleted() { if (!this.currentModule) return false; const moduleProgress = this.userProgress[this.currentModule.id]; return moduleProgress && moduleProgress.completed.includes(this.currentLessonIndex); } /** * Get completion status for a specific lesson * @param {string} moduleId - Module ID * @param {number} lessonIndex - Lesson index * @returns {boolean} Whether the lesson is completed */ isLessonCompleted(moduleId, lessonIndex) { const moduleProgress = this.userProgress[moduleId]; return moduleProgress && moduleProgress.completed.includes(lessonIndex); } /** * Get the current state of the lesson * @returns {Object} The current lesson state */ getCurrentState() { const currentModuleIndex = this.modules.findIndex((m) => m.id === this.currentModule?.id); const isLastLesson = this.currentLessonIndex >= (this.currentModule ? this.currentModule.lessons.length - 1 : 0); const isFirstLesson = this.currentLessonIndex === 0; const isLastModule = currentModuleIndex >= this.modules.length - 1; const isFirstModule = currentModuleIndex <= 0; return { module: this.currentModule, lesson: this.currentLesson, lessonIndex: this.currentLessonIndex, userCode: this.userCode, totalLessons: this.currentModule ? this.currentModule.lessons.length : 0, isCompleted: this.isCurrentLessonCompleted(), canGoNext: !isLastLesson || !isLastModule, canGoPrev: !isFirstLesson || !isFirstModule }; } /** * Get overall progress statistics with milestone data * @returns {Object} Progress statistics including milestone progress */ getProgressStats() { const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100]; let totalLessons = 0; let totalCompleted = 0; this.modules.forEach((module) => { // Skip modules excluded from progress (e.g., welcome, goodbye) if (module.excludeFromProgress) return; totalLessons += module.lessons.length; const progress = this.userProgress[module.id]; if (progress?.completed) { totalCompleted += progress.completed.length; } }); // Calculate milestone progress const milestonesReached = MILESTONES.filter((m) => totalCompleted >= m); const currentMilestone = milestonesReached[milestonesReached.length - 1] || 0; const nextMilestone = MILESTONES.find((m) => m > totalCompleted) || 100; const progressToNext = nextMilestone > currentMilestone ? Math.round(((totalCompleted - currentMilestone) / (nextMilestone - currentMilestone)) * 100) : 100; return { totalLessons, totalCompleted, percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0, // Milestone data milestones: MILESTONES, milestonesReached, currentMilestone, nextMilestone, progressToNext }; } /** * Save progress to localStorage and optionally sync to cloud */ saveUserProgress() { try { const progressData = { ...this.userProgress, lastModuleId: this.currentModule?.id, timestamp: new Date().toISOString() }; localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData)); // Trigger cloud sync if logged in (debounced) this.triggerCloudSync(); } catch (e) { console.error("Error saving progress:", e); } } /** * Trigger cloud sync if user is logged in (debounced) */ async triggerCloudSync() { const auth = await getAuthModule(); if (auth?.isLoggedIn()) { auth.debouncedSyncToCloud(); } } /** * Load progress from localStorage */ loadUserProgress() { try { const savedProgress = localStorage.getItem("codeCrispies.progress"); if (savedProgress) { const progressData = JSON.parse(savedProgress); // Extract user progress, excluding metadata const { lastModuleId, timestamp, ...userProgress } = progressData; this.userProgress = userProgress; return { lastModuleId, timestamp }; } } catch (e) { console.error("Error loading progress:", e); } return null; } /** * Save user code to localStorage and optionally sync to cloud */ saveUserCodeToStorage() { try { localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(this.userCodeMap.entries()))); // Trigger cloud sync if logged in (debounced) this.triggerCloudSync(); } catch (e) { console.error("Error saving user code:", e); } } /** * Load user code from localStorage */ loadUserCodeFromStorage() { try { const savedCode = localStorage.getItem("codeCrispies.userCode"); if (savedCode) { const codeEntries = JSON.parse(savedCode); this.userCodeMap = new Map(codeEntries); } } catch (e) { console.error("Error loading user code:", e); } } /** * Reset the current lesson */ reset() { if (this.currentLesson) { this.userCode = this.currentLesson.initialCode || ""; // Clear saved user code for this lesson const lessonKey = `${this.currentModule.id}-${this.currentLessonIndex}`; this.userCodeMap.delete(lessonKey); this.saveUserCodeToStorage(); // Remove lesson from completed list const progress = this.userProgress[this.currentModule.id]; if (progress?.completed) { const idx = progress.completed.indexOf(this.currentLessonIndex); if (idx !== -1) { progress.completed.splice(idx, 1); this.saveUserProgress(); } } this.renderPreview(); } } /** * Clear all saved progress and user code */ clearProgress() { this.userProgress = {}; this.userCodeMap.clear(); localStorage.removeItem("codeCrispies.progress"); localStorage.removeItem("codeCrispies.userCode"); localStorage.removeItem("codeCrispies.lastModuleId"); } }