diff --git a/src/app.js b/src/app.js index 7252e25..1c10151 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,6 @@ import { LessonEngine } from "./impl/LessonEngine.js"; import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.js"; -import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js"; +import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar, renderDifficultyBadge } from "./helpers/renderer.js"; import { loadModules } from "./config/lessons.js"; import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js"; import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js"; @@ -648,6 +648,9 @@ function loadCurrentLesson() { lesson ); + // Render difficulty badge + renderDifficultyBadge(elements.lessonTitleRow, lesson); + // Set user code in CodeMirror (clear history to prevent undo/redo across lessons) if (codeEditor) { codeEditor.setValueAndClearHistory(engineState.userCode); @@ -657,12 +660,13 @@ function loadCurrentLesson() { if (engineState.isCompleted) { elements.runBtn.querySelector("span").textContent = t("rerun"); - // Add completion badge if not present - if (!document.querySelector(".completion-badge")) { + // Add completion badge to difficulty-wrapper if not present + const wrapper = document.querySelector(".difficulty-wrapper"); + if (wrapper && !wrapper.querySelector(".completion-badge")) { const badge = document.createElement("span"); badge.className = "completion-badge"; badge.textContent = t("completed"); - elements.lessonTitleRow.appendChild(badge); + wrapper.appendChild(badge); } // Show gradient border and glow for completed lessons @@ -671,7 +675,7 @@ function loadCurrentLesson() { } else { elements.runBtn.querySelector("span").textContent = t("run"); - // Remove completion badge and border if exists + // Remove completion badge if exists const badge = document.querySelector(".completion-badge"); if (badge) badge.remove(); elements.previewWrapper?.classList.remove("completed-glow"); @@ -2480,6 +2484,11 @@ function init() { elements.closeSidebar.addEventListener("click", closeSidebar); elements.sidebarBackdrop.addEventListener("click", closeSidebar); + // Sidebar nav links (mobile) - close sidebar on click + document.querySelectorAll(".sidebar-nav-link").forEach((link) => { + link.addEventListener("click", closeSidebar); + }); + // Logo click - navigate to home landing elements.logoLink.addEventListener("click", (e) => { e.preventDefault(); diff --git a/src/auth.js b/src/auth.js index 6221e70..d5c4e10 100644 --- a/src/auth.js +++ b/src/auth.js @@ -153,6 +153,7 @@ function updateAuthUI(user) { // Sidebar elements const authTriggerSidebar = document.getElementById("auth-trigger-sidebar"); + const authTriggerMobile = document.getElementById("auth-trigger-mobile"); const userMenuSidebar = document.getElementById("user-menu-sidebar"); const userEmailSidebar = document.getElementById("user-email-sidebar"); const sidebarHint = document.querySelector(".sidebar-auth-hint"); @@ -161,6 +162,7 @@ function updateAuthUI(user) { authTriggerHeader?.classList.add("hidden"); userEmailHeader?.classList.remove("hidden"); authTriggerSidebar?.classList.add("hidden"); + authTriggerMobile?.classList.add("hidden"); userMenuSidebar?.classList.remove("hidden"); sidebarHint?.classList.add("hidden"); if (userEmailHeader) userEmailHeader.textContent = user.email; @@ -169,6 +171,7 @@ function updateAuthUI(user) { authTriggerHeader?.classList.remove("hidden"); userEmailHeader?.classList.add("hidden"); authTriggerSidebar?.classList.remove("hidden"); + authTriggerMobile?.classList.remove("hidden"); userMenuSidebar?.classList.add("hidden"); sidebarHint?.classList.remove("hidden"); } @@ -257,7 +260,7 @@ function setupAuthForms() { .getElementById("show-reset") ?.addEventListener("click", () => switchForm("reset")); - // Dialog triggers (both header and sidebar) + // Dialog triggers (header, sidebar, and mobile) document .getElementById("auth-trigger-header") ?.addEventListener("click", () => { @@ -268,6 +271,11 @@ function setupAuthForms() { ?.addEventListener("click", () => { authDialog?.showModal(); }); + document + .getElementById("auth-trigger-mobile") + ?.addEventListener("click", () => { + authDialog?.showModal(); + }); // Logout button (sidebar only) document diff --git a/src/helpers/renderer.js b/src/helpers/renderer.js index 3cae293..665cb61 100644 --- a/src/helpers/renderer.js +++ b/src/helpers/renderer.js @@ -3,6 +3,49 @@ */ import { t } from "../i18n.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; @@ -138,6 +181,42 @@ export function renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl // 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 diff --git a/src/i18n.js b/src/i18n.js index 1cf59ee..40f9b99 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -112,6 +112,12 @@ const translations = { // Dynamic content loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.", completed: "Completed", + difficulty_easy: "Easy", + difficulty_medium: "Medium", + difficulty_hard: "Hard", + difficulty_easy_label: "Easy difficulty - selector provided", + difficulty_medium_label: "Medium difficulty - simple selector required", + difficulty_hard_label: "Hard difficulty - compound selector required", successMessage: "CRISPY! ٩(◕‿◕)۶ Your code works correctly.", keepTrying: "Keep trying!", failedToLoad: "Failed to load modules. Please refresh the page.", @@ -336,7 +342,13 @@ const translations = { // Dynamic content loadingFallbackText: "Lektion konnte nicht geladen werden. Bitte wähle eine aus dem Menü oder prüfe die Hilfe.", - completed: "Erledigt", + completed: "Fertig", + difficulty_easy: "Einfach", + difficulty_medium: "Mittel", + difficulty_hard: "Schwer", + difficulty_easy_label: "Einfach - Selektor vorgegeben", + difficulty_medium_label: "Mittel - einfacher Selektor erforderlich", + difficulty_hard_label: "Schwer - zusammengesetzter Selektor erforderlich", successMessage: "CRISPY! ٩(◕‿◕)۶ Dein Code funktioniert.", keepTrying: "Weiter versuchen!", failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.", @@ -345,7 +357,7 @@ const translations = { untitledLesson: "Unbenannte Lektion", // Landing page - landingHeroTitle: "Lerne Web Entwicklung", + landingHeroTitle: "Web Entwicklung lernen", landingHeroHighlight: "mit CODE CRISPIES", landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.", landingCtaStart: "Jetzt starten", @@ -560,6 +572,12 @@ const translations = { // Dynamic content loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.", completed: "Ukończono", + difficulty_easy: "Łatwe", + difficulty_medium: "Średnie", + difficulty_hard: "Trudne", + difficulty_easy_label: "Łatwe - selektor podany", + difficulty_medium_label: "Średnie - wymagany prosty selektor", + difficulty_hard_label: "Trudne - wymagany złożony selektor", successMessage: "CRISPY! ٩(◕‿◕)۶ Twój kod działa poprawnie.", keepTrying: "Próbuj dalej!", failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.", @@ -784,6 +802,12 @@ const translations = { // Dynamic content loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.", completed: "Completado", + difficulty_easy: "Fácil", + difficulty_medium: "Medio", + difficulty_hard: "Difícil", + difficulty_easy_label: "Fácil - selector proporcionado", + difficulty_medium_label: "Medio - selector simple requerido", + difficulty_hard_label: "Difícil - selector compuesto requerido", successMessage: "¡CRISPY! ٩(◕‿◕)۶ Tu código funciona correctamente.", keepTrying: "¡Sigue intentando!", failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.", @@ -1007,6 +1031,12 @@ const translations = { // Dynamic content loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.", completed: "مكتمل", + difficulty_easy: "سهل", + difficulty_medium: "متوسط", + difficulty_hard: "صعب", + difficulty_easy_label: "سهل - المحدد مُعطى", + difficulty_medium_label: "متوسط - يتطلب محدد بسيط", + difficulty_hard_label: "صعب - يتطلب محدد مركب", successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.", keepTrying: "استمر في المحاولة!", failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.", @@ -1228,6 +1258,12 @@ const translations = { // Dynamic content loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.", completed: "Завершено", + difficulty_easy: "Легко", + difficulty_medium: "Середнє", + difficulty_hard: "Складно", + difficulty_easy_label: "Легко - селектор наданий", + difficulty_medium_label: "Середнє - потрібен простий селектор", + difficulty_hard_label: "Складно - потрібен складений селектор", successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.", keepTrying: "Продовжуйте спроби!", failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.", diff --git a/src/index.html b/src/index.html index feaf4fb..70e2cd2 100644 --- a/src/index.html +++ b/src/index.html @@ -463,6 +463,13 @@ + +