Implementation following plan: - S01: Foundation: schema, section config, and router - S02: Install CodeMirror JavaScript language support - S03: Create JavaScript lesson JSON files (variables, DOM, events) - S04: Register JavaScript lessons in module stores - S05: Add JavaScript validation logic - S06: Add JavaScript mode to LessonEngine preview rendering - S07: Add JavaScript mode to CodeEditor - S08: Update app.js for JavaScript mode support - S09: Update navigation HTML and CSS theming for JavaScript section - S10: Add section grouping headers in sidebar navigation - S11: Update and write tests
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
/**
|
|
* Renderer - Handles UI updates for the CSS learning platform
|
|
*/
|
|
import { t } from "../i18n.js";
|
|
import { getModuleSection, getSection, getSectionList } from "../config/sections.js";
|
|
|
|
/**
|
|
* Compute lesson difficulty based on lesson structure
|
|
* - Easy: selector is provided in codePrefix (student only writes properties)
|
|
* - Medium: student writes a simple selector (single element/class)
|
|
* - Hard: student writes compound selectors (descendant, chained classes, type+class)
|
|
* @param {Object} lesson - The lesson object
|
|
* @returns {"easy"|"medium"|"hard"} The computed difficulty
|
|
*/
|
|
export function computeLessonDifficulty(lesson) {
|
|
const codePrefix = lesson.codePrefix || "";
|
|
const solution = lesson.solution || "";
|
|
|
|
// If codePrefix contains an opening brace, selector is provided → Easy
|
|
if (codePrefix.includes("{")) {
|
|
return "easy";
|
|
}
|
|
|
|
// No codePrefix with selector - check the solution complexity
|
|
// Hard: descendant selectors (space before {), chained classes (.a.b), type+class (a.class)
|
|
const selectorMatch = solution.match(/^([^{]+)\{/);
|
|
if (selectorMatch) {
|
|
const selector = selectorMatch[1].trim();
|
|
|
|
// Descendant selector: has space (e.g., ".nav a", ".card p")
|
|
if (/\S\s+\S/.test(selector)) {
|
|
return "hard";
|
|
}
|
|
|
|
// Chained classes: multiple dots without space (e.g., ".btn.primary")
|
|
if ((selector.match(/\./g) || []).length > 1) {
|
|
return "hard";
|
|
}
|
|
|
|
// Type + class: element followed by dot (e.g., "a.btn", "div.card")
|
|
if (/^[a-z]+\.[a-z]/i.test(selector)) {
|
|
return "hard";
|
|
}
|
|
}
|
|
|
|
// Simple selector → Medium
|
|
return "medium";
|
|
}
|
|
|
|
// Feedback elements cache
|
|
let feedbackElement = null;
|
|
let feedbackTimeout = null;
|
|
|
|
/**
|
|
* Render the module list in the sidebar with expandable lessons
|
|
* @param {HTMLElement} container - The container element for the module list
|
|
* @param {Array} modules - The list of modules
|
|
* @param {Function} onSelectModule - Callback when a module is selected
|
|
* @param {Function} onSelectLesson - Callback when a lesson is selected
|
|
*/
|
|
export function renderModuleList(container, modules, onSelectModule, onSelectLesson) {
|
|
// Clear the container
|
|
container.innerHTML = "";
|
|
|
|
// Get user progress from localStorage
|
|
const progressData = localStorage.getItem("codeCrispies.progress");
|
|
let progress = {};
|
|
if (progressData) {
|
|
try {
|
|
progress = JSON.parse(progressData);
|
|
} catch (e) {
|
|
console.error("Error parsing progress data:", e);
|
|
}
|
|
}
|
|
|
|
// Group modules by section for headers
|
|
let currentSectionId = null;
|
|
|
|
// Create list items for each module
|
|
modules.forEach((module) => {
|
|
// Insert section header when section changes
|
|
const sectionId = getModuleSection(module);
|
|
if (sectionId !== currentSectionId && !module.excludeFromProgress) {
|
|
currentSectionId = sectionId;
|
|
const section = getSection(sectionId);
|
|
if (section) {
|
|
const header = document.createElement("h3");
|
|
header.className = "sidebar-section-header";
|
|
header.textContent = section.title;
|
|
header.style.borderLeftColor = section.color;
|
|
container.appendChild(header);
|
|
}
|
|
}
|
|
// Create module container
|
|
// Use native <details>/<summary> for expand/collapse
|
|
const moduleContainer = document.createElement("details");
|
|
moduleContainer.classList.add("module-container");
|
|
moduleContainer.dataset.moduleId = module.id;
|
|
|
|
// Keep welcome and playground modules always expanded
|
|
if (module.id === "welcome" || module.id === "playground") {
|
|
moduleContainer.open = true;
|
|
}
|
|
|
|
// Create module header using <summary>
|
|
const moduleHeader = document.createElement("summary");
|
|
moduleHeader.classList.add("module-list-item", "module-header");
|
|
moduleHeader.dataset.moduleId = module.id;
|
|
|
|
// Create module title
|
|
const moduleTitle = document.createElement("span");
|
|
moduleTitle.classList.add("module-title");
|
|
moduleTitle.textContent = module.title;
|
|
|
|
moduleHeader.appendChild(moduleTitle);
|
|
|
|
// Check if the module is completed
|
|
if (progress[module.id] && progress[module.id].completed.length === module.lessons.length) {
|
|
moduleHeader.classList.add("completed");
|
|
}
|
|
|
|
// Lessons container
|
|
const lessonsContainer = document.createElement("div");
|
|
lessonsContainer.classList.add("lessons-container");
|
|
lessonsContainer.id = `lessons-${module.id}`;
|
|
|
|
// Create list items for each lesson in this module
|
|
module.lessons.forEach((lesson, index) => {
|
|
const lessonItem = document.createElement("button");
|
|
lessonItem.type = "button";
|
|
lessonItem.classList.add("lesson-list-item");
|
|
lessonItem.dataset.moduleId = module.id;
|
|
lessonItem.dataset.lessonIndex = index;
|
|
lessonItem.textContent = lesson.title || t("lessonFallback", { index: index + 1 });
|
|
|
|
// Mark lesson as completed if in progress data
|
|
if (progress[module.id] && progress[module.id].completed.includes(index)) {
|
|
lessonItem.classList.add("completed");
|
|
}
|
|
|
|
// Mark lesson as current if it's the current lesson
|
|
if (progress[module.id] && progress[module.id].current === index) {
|
|
lessonItem.classList.add("current");
|
|
}
|
|
|
|
// Add click event to select lesson
|
|
lessonItem.addEventListener("click", () => {
|
|
// Update UI to show this lesson is selected
|
|
document.querySelectorAll(".lesson-list-item").forEach((item) => {
|
|
item.classList.remove("active");
|
|
});
|
|
lessonItem.classList.add("active");
|
|
|
|
// Call the onSelectLesson callback
|
|
onSelectLesson(module.id, index);
|
|
});
|
|
|
|
lessonsContainer.appendChild(lessonItem);
|
|
});
|
|
|
|
// Add module header and lessons container to module container
|
|
moduleContainer.appendChild(moduleHeader);
|
|
moduleContainer.appendChild(lessonsContainer);
|
|
|
|
// Add the complete module container to the sidebar
|
|
container.appendChild(moduleContainer);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render a lesson in the UI
|
|
* @param {HTMLElement} titleEl - The lesson title element
|
|
* @param {HTMLElement} descriptionEl - The lesson description element
|
|
* @param {HTMLElement} taskEl - The task instruction element
|
|
* @param {HTMLElement} previewEl - The preview area element
|
|
* @param {HTMLElement} prefixEl - The code editor prefix element
|
|
* @param {HTMLElement} inputEl - The code input element
|
|
* @param {HTMLElement} suffixEl - The code editor suffix element
|
|
* @param {Object} lesson - The lesson object
|
|
*/
|
|
export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl, inputEl, suffixEl, lesson) {
|
|
// Set lesson title and description
|
|
titleEl.textContent = lesson.title || t("untitledLesson");
|
|
descriptionEl.innerHTML = lesson.description || "";
|
|
|
|
// Set task instructions
|
|
taskEl.innerHTML = lesson.task || "";
|
|
|
|
// Set code editor contents (if inputEl is provided)
|
|
if (inputEl) {
|
|
inputEl.value = lesson.initialCode || "";
|
|
}
|
|
|
|
// Clear any existing feedback
|
|
clearFeedback();
|
|
|
|
// Initial preview render with empty user code
|
|
// The LessonEngine will handle this when it's first set
|
|
}
|
|
|
|
/**
|
|
* Render the difficulty badge (right-aligned in title row)
|
|
* @param {HTMLElement} container - The container element (lesson-title-row)
|
|
* @param {Object} lesson - The lesson object
|
|
*/
|
|
export function renderDifficultyBadge(container, lesson) {
|
|
// Remove existing difficulty wrapper if any
|
|
const existingWrapper = container.querySelector(".difficulty-wrapper");
|
|
if (existingWrapper) {
|
|
existingWrapper.remove();
|
|
}
|
|
|
|
// Compute difficulty
|
|
const difficulty = computeLessonDifficulty(lesson);
|
|
|
|
// Create wrapper for right-alignment
|
|
const wrapper = document.createElement("span");
|
|
wrapper.className = "difficulty-wrapper";
|
|
|
|
// Create badge element with three bars
|
|
const badge = document.createElement("span");
|
|
badge.className = `difficulty-badge difficulty-${difficulty}`;
|
|
badge.setAttribute("aria-label", t(`difficulty_${difficulty}_label`));
|
|
badge.setAttribute("title", t(`difficulty_${difficulty}`));
|
|
|
|
// Add three bars
|
|
for (let i = 0; i < 3; i++) {
|
|
const bar = document.createElement("span");
|
|
bar.className = "bar";
|
|
badge.appendChild(bar);
|
|
}
|
|
|
|
wrapper.appendChild(badge);
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
/**
|
|
* Update the level indicator
|
|
* @param {HTMLElement} element - The level indicator element
|
|
* @param {number} current - The current level number
|
|
* @param {number} total - The total number of levels
|
|
*/
|
|
export function renderLevelIndicator(element, current, total) {
|
|
const label = t("lessonLabel");
|
|
element.innerHTML = `<span class="level-label">${label}</span> ${current} / ${total}`;
|
|
}
|
|
|
|
/**
|
|
* Show feedback for user submissions
|
|
* @param {boolean} isSuccess - Whether the submission was successful
|
|
* @param {string} message - The feedback message
|
|
*/
|
|
export function showFeedback(isSuccess, message) {
|
|
// Clear any existing feedback
|
|
clearFeedback();
|
|
|
|
// Check if error feedback is disabled in user settings
|
|
if (!isSuccess) {
|
|
const disableFeedbackErrors = !document.getElementById("disable-feedback-toggle").checked;
|
|
if (disableFeedbackErrors) {
|
|
// Skip showing error feedback if disabled
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create feedback element
|
|
feedbackElement = document.createElement("div");
|
|
feedbackElement.classList.add(isSuccess ? "feedback-success" : "feedback-error");
|
|
feedbackElement.innerHTML = message;
|
|
|
|
// Find where to insert the feedback
|
|
const insertAfter = document.querySelector(".editor-content");
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(feedbackElement, insertAfter.nextSibling);
|
|
}
|
|
|
|
if (!isSuccess) {
|
|
feedbackTimeout = setTimeout(clearFeedback, 3_000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear any existing feedback
|
|
*/
|
|
export function clearFeedback() {
|
|
if (feedbackTimeout) {
|
|
clearInterval(feedbackTimeout);
|
|
}
|
|
|
|
if (feedbackElement && feedbackElement.parentNode) {
|
|
feedbackElement.parentNode.removeChild(feedbackElement);
|
|
}
|
|
feedbackElement = null;
|
|
}
|
|
|
|
/**
|
|
* Update the active lesson in the sidebar
|
|
* @param {string} moduleId - The ID of the module
|
|
* @param {number} lessonIndex - The index of the lesson
|
|
*/
|
|
export function updateActiveLessonInSidebar(moduleId, lessonIndex) {
|
|
// Remove active class from all lessons
|
|
document.querySelectorAll(".lesson-list-item").forEach((item) => {
|
|
item.classList.remove("active");
|
|
});
|
|
|
|
// Find and activate the current lesson
|
|
const selector = `.lesson-list-item[data-module-id="${moduleId}"][data-lesson-index="${lessonIndex}"]`;
|
|
const currentLessonItem = document.querySelector(selector);
|
|
|
|
if (currentLessonItem) {
|
|
currentLessonItem.classList.add("active");
|
|
|
|
// Make sure parent module is expanded (native <details> element)
|
|
const parentDetails = currentLessonItem.closest("details.module-container");
|
|
if (parentDetails) {
|
|
parentDetails.open = true;
|
|
|
|
// Scroll to the top of the page
|
|
document.querySelector("html").scrollTop = 0;
|
|
document.body.scrollTop = 0;
|
|
// Scroll to the current lesson item
|
|
currentLessonItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
}
|
|
}
|