feat: add lesson difficulty indicators and improve mobile sidebar
- Add computeLessonDifficulty function to determine lesson difficulty based on selector complexity (easy/medium/hard) - Display difficulty badge with bar indicator in lesson title row - Add mobile navigation links (CSS, HTML, Tailwind) to sidebar - Add mobile auth trigger button in sidebar - Redesign settings section with card layout and native toggles - Add difficulty translations for all 6 languages - Fix module pill overflow on narrow screens
This commit is contained in:
19
src/app.js
19
src/app.js
@@ -1,6 +1,6 @@
|
|||||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||||
import { CodeEditor, crispyEditorTheme } from "./impl/CodeEditor.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 { loadModules } from "./config/lessons.js";
|
||||||
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js";
|
||||||
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
|
import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js";
|
||||||
@@ -648,6 +648,9 @@ function loadCurrentLesson() {
|
|||||||
lesson
|
lesson
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render difficulty badge
|
||||||
|
renderDifficultyBadge(elements.lessonTitleRow, lesson);
|
||||||
|
|
||||||
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
|
// Set user code in CodeMirror (clear history to prevent undo/redo across lessons)
|
||||||
if (codeEditor) {
|
if (codeEditor) {
|
||||||
codeEditor.setValueAndClearHistory(engineState.userCode);
|
codeEditor.setValueAndClearHistory(engineState.userCode);
|
||||||
@@ -657,12 +660,13 @@ function loadCurrentLesson() {
|
|||||||
if (engineState.isCompleted) {
|
if (engineState.isCompleted) {
|
||||||
elements.runBtn.querySelector("span").textContent = t("rerun");
|
elements.runBtn.querySelector("span").textContent = t("rerun");
|
||||||
|
|
||||||
// Add completion badge if not present
|
// Add completion badge to difficulty-wrapper if not present
|
||||||
if (!document.querySelector(".completion-badge")) {
|
const wrapper = document.querySelector(".difficulty-wrapper");
|
||||||
|
if (wrapper && !wrapper.querySelector(".completion-badge")) {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "completion-badge";
|
badge.className = "completion-badge";
|
||||||
badge.textContent = t("completed");
|
badge.textContent = t("completed");
|
||||||
elements.lessonTitleRow.appendChild(badge);
|
wrapper.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show gradient border and glow for completed lessons
|
// Show gradient border and glow for completed lessons
|
||||||
@@ -671,7 +675,7 @@ function loadCurrentLesson() {
|
|||||||
} else {
|
} else {
|
||||||
elements.runBtn.querySelector("span").textContent = t("run");
|
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");
|
const badge = document.querySelector(".completion-badge");
|
||||||
if (badge) badge.remove();
|
if (badge) badge.remove();
|
||||||
elements.previewWrapper?.classList.remove("completed-glow");
|
elements.previewWrapper?.classList.remove("completed-glow");
|
||||||
@@ -2480,6 +2484,11 @@ function init() {
|
|||||||
elements.closeSidebar.addEventListener("click", closeSidebar);
|
elements.closeSidebar.addEventListener("click", closeSidebar);
|
||||||
elements.sidebarBackdrop.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
|
// Logo click - navigate to home landing
|
||||||
elements.logoLink.addEventListener("click", (e) => {
|
elements.logoLink.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
10
src/auth.js
10
src/auth.js
@@ -153,6 +153,7 @@ function updateAuthUI(user) {
|
|||||||
|
|
||||||
// Sidebar elements
|
// Sidebar elements
|
||||||
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
|
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
|
||||||
|
const authTriggerMobile = document.getElementById("auth-trigger-mobile");
|
||||||
const userMenuSidebar = document.getElementById("user-menu-sidebar");
|
const userMenuSidebar = document.getElementById("user-menu-sidebar");
|
||||||
const userEmailSidebar = document.getElementById("user-email-sidebar");
|
const userEmailSidebar = document.getElementById("user-email-sidebar");
|
||||||
const sidebarHint = document.querySelector(".sidebar-auth-hint");
|
const sidebarHint = document.querySelector(".sidebar-auth-hint");
|
||||||
@@ -161,6 +162,7 @@ function updateAuthUI(user) {
|
|||||||
authTriggerHeader?.classList.add("hidden");
|
authTriggerHeader?.classList.add("hidden");
|
||||||
userEmailHeader?.classList.remove("hidden");
|
userEmailHeader?.classList.remove("hidden");
|
||||||
authTriggerSidebar?.classList.add("hidden");
|
authTriggerSidebar?.classList.add("hidden");
|
||||||
|
authTriggerMobile?.classList.add("hidden");
|
||||||
userMenuSidebar?.classList.remove("hidden");
|
userMenuSidebar?.classList.remove("hidden");
|
||||||
sidebarHint?.classList.add("hidden");
|
sidebarHint?.classList.add("hidden");
|
||||||
if (userEmailHeader) userEmailHeader.textContent = user.email;
|
if (userEmailHeader) userEmailHeader.textContent = user.email;
|
||||||
@@ -169,6 +171,7 @@ function updateAuthUI(user) {
|
|||||||
authTriggerHeader?.classList.remove("hidden");
|
authTriggerHeader?.classList.remove("hidden");
|
||||||
userEmailHeader?.classList.add("hidden");
|
userEmailHeader?.classList.add("hidden");
|
||||||
authTriggerSidebar?.classList.remove("hidden");
|
authTriggerSidebar?.classList.remove("hidden");
|
||||||
|
authTriggerMobile?.classList.remove("hidden");
|
||||||
userMenuSidebar?.classList.add("hidden");
|
userMenuSidebar?.classList.add("hidden");
|
||||||
sidebarHint?.classList.remove("hidden");
|
sidebarHint?.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
@@ -257,7 +260,7 @@ function setupAuthForms() {
|
|||||||
.getElementById("show-reset")
|
.getElementById("show-reset")
|
||||||
?.addEventListener("click", () => switchForm("reset"));
|
?.addEventListener("click", () => switchForm("reset"));
|
||||||
|
|
||||||
// Dialog triggers (both header and sidebar)
|
// Dialog triggers (header, sidebar, and mobile)
|
||||||
document
|
document
|
||||||
.getElementById("auth-trigger-header")
|
.getElementById("auth-trigger-header")
|
||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
@@ -268,6 +271,11 @@ function setupAuthForms() {
|
|||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
authDialog?.showModal();
|
authDialog?.showModal();
|
||||||
});
|
});
|
||||||
|
document
|
||||||
|
.getElementById("auth-trigger-mobile")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
authDialog?.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
// Logout button (sidebar only)
|
// Logout button (sidebar only)
|
||||||
document
|
document
|
||||||
|
|||||||
@@ -3,6 +3,49 @@
|
|||||||
*/
|
*/
|
||||||
import { t } from "../i18n.js";
|
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
|
// Feedback elements cache
|
||||||
let feedbackElement = null;
|
let feedbackElement = null;
|
||||||
let feedbackTimeout = 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
|
// 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
|
* Update the level indicator
|
||||||
* @param {HTMLElement} element - The level indicator element
|
* @param {HTMLElement} element - The level indicator element
|
||||||
|
|||||||
40
src/i18n.js
40
src/i18n.js
@@ -112,6 +112,12 @@ const translations = {
|
|||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.",
|
loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.",
|
||||||
completed: "Completed",
|
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.",
|
successMessage: "CRISPY! ٩(◕‿◕)۶ Your code works correctly.",
|
||||||
keepTrying: "Keep trying!",
|
keepTrying: "Keep trying!",
|
||||||
failedToLoad: "Failed to load modules. Please refresh the page.",
|
failedToLoad: "Failed to load modules. Please refresh the page.",
|
||||||
@@ -336,7 +342,13 @@ const translations = {
|
|||||||
|
|
||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "Lektion konnte nicht geladen werden. Bitte wähle eine aus dem Menü oder prüfe die Hilfe.",
|
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.",
|
successMessage: "CRISPY! ٩(◕‿◕)۶ Dein Code funktioniert.",
|
||||||
keepTrying: "Weiter versuchen!",
|
keepTrying: "Weiter versuchen!",
|
||||||
failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.",
|
failedToLoad: "Module konnten nicht geladen werden. Bitte Seite neu laden.",
|
||||||
@@ -345,7 +357,7 @@ const translations = {
|
|||||||
untitledLesson: "Unbenannte Lektion",
|
untitledLesson: "Unbenannte Lektion",
|
||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
landingHeroTitle: "Lerne Web Entwicklung",
|
landingHeroTitle: "Web Entwicklung lernen",
|
||||||
landingHeroHighlight: "mit CODE CRISPIES",
|
landingHeroHighlight: "mit CODE CRISPIES",
|
||||||
landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.",
|
landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.",
|
||||||
landingCtaStart: "Jetzt starten",
|
landingCtaStart: "Jetzt starten",
|
||||||
@@ -560,6 +572,12 @@ const translations = {
|
|||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.",
|
loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.",
|
||||||
completed: "Ukończono",
|
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.",
|
successMessage: "CRISPY! ٩(◕‿◕)۶ Twój kod działa poprawnie.",
|
||||||
keepTrying: "Próbuj dalej!",
|
keepTrying: "Próbuj dalej!",
|
||||||
failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.",
|
failedToLoad: "Nie udało się załadować modułów. Odśwież stronę.",
|
||||||
@@ -784,6 +802,12 @@ const translations = {
|
|||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.",
|
loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.",
|
||||||
completed: "Completado",
|
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.",
|
successMessage: "¡CRISPY! ٩(◕‿◕)۶ Tu código funciona correctamente.",
|
||||||
keepTrying: "¡Sigue intentando!",
|
keepTrying: "¡Sigue intentando!",
|
||||||
failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.",
|
failedToLoad: "No se pudieron cargar los módulos. Actualiza la página.",
|
||||||
@@ -1007,6 +1031,12 @@ const translations = {
|
|||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.",
|
loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.",
|
||||||
completed: "مكتمل",
|
completed: "مكتمل",
|
||||||
|
difficulty_easy: "سهل",
|
||||||
|
difficulty_medium: "متوسط",
|
||||||
|
difficulty_hard: "صعب",
|
||||||
|
difficulty_easy_label: "سهل - المحدد مُعطى",
|
||||||
|
difficulty_medium_label: "متوسط - يتطلب محدد بسيط",
|
||||||
|
difficulty_hard_label: "صعب - يتطلب محدد مركب",
|
||||||
successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.",
|
successMessage: "CRISPY! ٩(◕‿◕)۶ الكود يعمل بشكل صحيح.",
|
||||||
keepTrying: "استمر في المحاولة!",
|
keepTrying: "استمر في المحاولة!",
|
||||||
failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.",
|
failedToLoad: "فشل تحميل الوحدات. قم بتحديث الصفحة.",
|
||||||
@@ -1228,6 +1258,12 @@ const translations = {
|
|||||||
// Dynamic content
|
// Dynamic content
|
||||||
loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.",
|
loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.",
|
||||||
completed: "Завершено",
|
completed: "Завершено",
|
||||||
|
difficulty_easy: "Легко",
|
||||||
|
difficulty_medium: "Середнє",
|
||||||
|
difficulty_hard: "Складно",
|
||||||
|
difficulty_easy_label: "Легко - селектор наданий",
|
||||||
|
difficulty_medium_label: "Середнє - потрібен простий селектор",
|
||||||
|
difficulty_hard_label: "Складно - потрібен складений селектор",
|
||||||
successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.",
|
successMessage: "CRISPY! ٩(◕‿◕)۶ Ваш код працює правильно.",
|
||||||
keepTrying: "Продовжуйте спроби!",
|
keepTrying: "Продовжуйте спроби!",
|
||||||
failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.",
|
failedToLoad: "Не вдалося завантажити модулі. Оновіть сторінку.",
|
||||||
|
|||||||
@@ -463,6 +463,13 @@
|
|||||||
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">×</button>
|
<button id="close-sidebar" class="close-btn" data-i18n-aria-label="closeMenu" aria-label="Close menu">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-section sidebar-nav-mobile" aria-label="Learning paths">
|
||||||
|
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
|
||||||
|
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
|
||||||
|
<a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a>
|
||||||
|
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h4 data-i18n="progress">Progress</h4>
|
<h4 data-i18n="progress">Progress</h4>
|
||||||
<div class="progress-display milestone-progress" id="progress-display">
|
<div class="progress-display milestone-progress" id="progress-display">
|
||||||
@@ -504,23 +511,27 @@
|
|||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h4 data-i18n="settings">Settings</h4>
|
<h4 data-i18n="settings">Settings</h4>
|
||||||
<label class="setting-row">
|
<div class="settings-card">
|
||||||
<span class="setting-label" data-i18n="language">Language</span>
|
<label class="settings-row">
|
||||||
<select id="lang-select" class="lang-select">
|
<span class="settings-label" data-i18n="language">Language</span>
|
||||||
<option value="en">English</option>
|
<select id="lang-select" class="lang-select">
|
||||||
<option value="de">Deutsch</option>
|
<option value="en">English</option>
|
||||||
<option value="pl">Polski</option>
|
<option value="de">Deutsch</option>
|
||||||
<option value="es">Español</option>
|
<option value="pl">Polski</option>
|
||||||
<option value="ar">العربية</option>
|
<option value="es">Español</option>
|
||||||
<option value="uk">Українська</option>
|
<option value="ar">العربية</option>
|
||||||
</select>
|
<option value="uk">Українська</option>
|
||||||
</label>
|
</select>
|
||||||
<label class="toggle-switch">
|
</label>
|
||||||
<input type="checkbox" id="disable-feedback-toggle" checked />
|
<label class="settings-row">
|
||||||
<span class="toggle-slider"></span>
|
<span class="settings-label" data-i18n="showHints">Show Hints</span>
|
||||||
<span class="toggle-label" data-i18n="showHints">Show Hints</span>
|
<input type="checkbox" id="disable-feedback-toggle" class="settings-toggle" checked />
|
||||||
</label>
|
</label>
|
||||||
<button id="reset-btn" class="btn btn-text" data-i18n="resetAllProgress">Reset All Progress</button>
|
<div class="settings-row">
|
||||||
|
<span class="settings-label" data-i18n="resetAllProgress">Reset All Progress</span>
|
||||||
|
<button id="reset-btn" class="btn btn-sm btn-ghost" data-i18n="reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
|
|||||||
168
src/main.css
168
src/main.css
@@ -308,6 +308,16 @@ kbd {
|
|||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#auth-trigger-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
#auth-trigger-header {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= GAME LAYOUT ================= */
|
/* ================= GAME LAYOUT ================= */
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -374,6 +384,7 @@ kbd {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: var(--primary-bg-medium);
|
background: var(--primary-bg-medium);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
min-width: 0;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -385,12 +396,18 @@ kbd {
|
|||||||
.module-name {
|
.module-name {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-pill .level-indicator {
|
.module-pill .level-indicator {
|
||||||
color: var(--primary-dark);
|
color: var(--primary-dark);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-title-row {
|
.lesson-title-row {
|
||||||
@@ -398,7 +415,13 @@ kbd {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-sm);
|
||||||
flex-wrap: wrap;
|
}
|
||||||
|
|
||||||
|
.lesson-title-row .difficulty-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lesson-title {
|
#lesson-title {
|
||||||
@@ -447,6 +470,28 @@ kbd {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.difficulty-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-badge .bar {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-badge .bar:nth-child(1) { height: 6px; }
|
||||||
|
.difficulty-badge .bar:nth-child(2) { height: 9px; }
|
||||||
|
.difficulty-badge .bar:nth-child(3) { height: 12px; }
|
||||||
|
|
||||||
|
.difficulty-easy .bar:nth-child(1),
|
||||||
|
.difficulty-medium .bar:nth-child(1),
|
||||||
|
.difficulty-medium .bar:nth-child(2),
|
||||||
|
.difficulty-hard .bar { background: var(--light-text); }
|
||||||
|
|
||||||
.lesson-description {
|
.lesson-description {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -996,14 +1041,69 @@ kbd {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile navigation in sidebar */
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 0.6rem var(--spacing-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-link:hover {
|
||||||
|
background: var(--primary-bg-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-auth-link {
|
||||||
|
width: calc(100% - 2 * var(--spacing-md));
|
||||||
|
margin: var(--spacing-sm) var(--spacing-md);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-auth-link:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Make the lessons nav section fill available space */
|
/* Make the lessons nav section fill available space */
|
||||||
nav.sidebar-section {
|
nav.sidebar-section:not(.sidebar-nav-mobile) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-bottom: var(--spacing-md);
|
padding-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-mobile {
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-section h4 {
|
.sidebar-section h4 {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1388,8 +1488,63 @@ button.lesson-list-item {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= TOGGLE SWITCH ================= */
|
/* ================= SETTINGS CARD ================= */
|
||||||
/* Setting row (for label + control) */
|
.settings-card {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 11px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle:checked {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle:checked::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy setting row (for label + control) */
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3177,11 +3332,16 @@ input:checked + .toggle-slider::before {
|
|||||||
|
|
||||||
.module-pill {
|
.module-pill {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
margin: 0 var(--spacing-sm);
|
margin: 0 var(--spacing-sm);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-name {
|
.module-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback } from "../../src/helpers/renderer.js";
|
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback, computeLessonDifficulty } from "../../src/helpers/renderer.js";
|
||||||
|
|
||||||
describe("Renderer Module", () => {
|
describe("Renderer Module", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -176,4 +176,68 @@ describe("Renderer Module", () => {
|
|||||||
clearFeedback();
|
clearFeedback();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeLessonDifficulty", () => {
|
||||||
|
test("should return 'easy' when codePrefix contains selector", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: ".text {\n ",
|
||||||
|
solution: "color: coral;"
|
||||||
|
})).toBe("easy");
|
||||||
|
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "h1, h2, h3 {\n ",
|
||||||
|
solution: "color: steelblue;"
|
||||||
|
})).toBe("easy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'medium' for simple type selector", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: "p {\n color: steelblue;\n}"
|
||||||
|
})).toBe("medium");
|
||||||
|
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: "a {\n color: coral;\n}"
|
||||||
|
})).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'medium' for simple class selector", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: ".badge {\n background: tomato;\n}"
|
||||||
|
})).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'hard' for descendant selectors", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: ".nav a {\n color: white;\n}"
|
||||||
|
})).toBe("hard");
|
||||||
|
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: ".card p {\n font-size: 0.9rem;\n}"
|
||||||
|
})).toBe("hard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'hard' for chained class selectors", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: ".btn.primary {\n background: steelblue;\n}"
|
||||||
|
})).toBe("hard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 'hard' for type+class selectors", () => {
|
||||||
|
expect(computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: "a.btn {\n text-decoration: none;\n}"
|
||||||
|
})).toBe("hard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle missing fields gracefully", () => {
|
||||||
|
expect(computeLessonDifficulty({})).toBe("medium");
|
||||||
|
expect(computeLessonDifficulty({ codePrefix: null })).toBe("medium");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user