diff --git a/lessons/00-welcome.json b/lessons/00-welcome.json index bcf8724..c56b700 100644 --- a/lessons/00-welcome.json +++ b/lessons/00-welcome.json @@ -5,6 +5,7 @@ "description": "Welcome to Code Crispies - your interactive web development learning platform", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/lessons/99-goodbye.json b/lessons/99-goodbye.json index 7a34dea..5d82431 100644 --- a/lessons/99-goodbye.json +++ b/lessons/99-goodbye.json @@ -5,6 +5,7 @@ "description": "Congratulations on completing your learning journey!", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "congratulations", diff --git a/lessons/ar/00-welcome.json b/lessons/ar/00-welcome.json index be26ed2..4a96ac4 100644 --- a/lessons/ar/00-welcome.json +++ b/lessons/ar/00-welcome.json @@ -5,6 +5,7 @@ "description": "مرحباً بك في Code Crispies - منصتك التفاعلية لتعلم تطوير الويب", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/lessons/de/00-welcome.json b/lessons/de/00-welcome.json index 8c29486..ad3dc7a 100644 --- a/lessons/de/00-welcome.json +++ b/lessons/de/00-welcome.json @@ -5,6 +5,7 @@ "description": "Willkommen bei Code Crispies - deine interaktive Lernplattform für Webentwicklung", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/lessons/es/00-welcome.json b/lessons/es/00-welcome.json index 8ff2960..2f55f3c 100644 --- a/lessons/es/00-welcome.json +++ b/lessons/es/00-welcome.json @@ -5,6 +5,7 @@ "description": "Bienvenido a Code Crispies - tu plataforma interactiva de aprendizaje de desarrollo web", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/lessons/pl/00-welcome.json b/lessons/pl/00-welcome.json index 3b7fb8c..c23511c 100644 --- a/lessons/pl/00-welcome.json +++ b/lessons/pl/00-welcome.json @@ -5,6 +5,7 @@ "description": "Witaj w Code Crispies - twojej interaktywnej platformie do nauki tworzenia stron", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/lessons/uk/00-welcome.json b/lessons/uk/00-welcome.json index 5e10fd7..9e2a463 100644 --- a/lessons/uk/00-welcome.json +++ b/lessons/uk/00-welcome.json @@ -5,6 +5,7 @@ "description": "Ласкаво просимо до Code Crispies - вашої інтерактивної платформи для вивчення веб-розробки", "mode": "html", "difficulty": "beginner", + "excludeFromProgress": true, "lessons": [ { "id": "get-started", diff --git a/src/app.js b/src/app.js index 68d1686..5e884e4 100644 --- a/src/app.js +++ b/src/app.js @@ -3,7 +3,8 @@ import { CodeEditor } from "./impl/CodeEditor.js"; import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js"; import { loadModules } from "./config/lessons.js"; import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n.js"; -import { parseHash, updateHash, replaceHash, getShareableUrl } from "./helpers/router.js"; +import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js"; +import { sections, getSection, getModuleSection, getModulesBySection } from "./config/sections.js"; // Simplified state - LessonEngine now manages lesson state and progress const state = { @@ -22,6 +23,19 @@ const elements = { logoLink: document.getElementById("logo-link"), langSelect: document.getElementById("lang-select"), helpBtn: document.getElementById("help-btn"), + mainNav: document.getElementById("main-nav"), + + // Page containers + landingPage: document.getElementById("landing-page"), + sectionPage: document.getElementById("section-page"), + gameLayout: document.getElementById("main-content"), + + // Section page elements + sectionTitle: document.getElementById("section-title"), + sectionDescription: document.getElementById("section-description"), + sectionProgressFill: document.getElementById("section-progress-fill"), + sectionProgressText: document.getElementById("section-progress-text"), + moduleGrid: document.getElementById("module-grid"), // Left panel instructionsSection: document.querySelector(".instructions"), @@ -293,23 +307,8 @@ function initializeModules() { // Use the new renderModuleList function with both callbacks renderModuleList(elements.moduleList, modules, selectModule, selectLesson); - // Check URL first for shareable links - const urlState = parseHash(); - - if (urlState) { - // URL takes priority - navigate to specified lesson - navigateToLesson(urlState.moduleId, urlState.lessonIndex, false); - } else { - // No URL - use saved progress (existing logic) - const progressData = lessonEngine.loadUserProgress(); - const lastModuleId = progressData?.lastModuleId; - - if (lastModuleId && modules.find((m) => m.id === lastModuleId)) { - selectModule(lastModuleId); - } else if (modules.length > 0) { - selectModule(modules[0].id); - } - } + // Handle route (home, section, or lesson) + handleRoute(false); updateProgressDisplay(); clearLoadingTimeout(); @@ -325,6 +324,9 @@ function selectModule(moduleId) { const success = lessonEngine.setModuleById(moduleId); if (!success) return; + // Show lesson UI + showLessonUI(); + // Update URL const engineState = lessonEngine.getCurrentState(); updateHash(moduleId, engineState.lessonIndex); @@ -355,6 +357,9 @@ function selectLesson(moduleId, lessonIndex) { lessonEngine.setLessonByIndex(lessonIndex); + // Show lesson UI + showLessonUI(); + // Update URL updateHash(moduleId, lessonIndex); @@ -814,7 +819,7 @@ async function copyShareUrl() { } } -// ================= URL ROUTING ================= +// ================= URL ROUTING & PAGE SWITCHING ================= function initRouter() { // Handle browser back/forward @@ -822,13 +827,196 @@ function initRouter() { } function handlePopState() { - const parsed = parseHash(); - if (parsed) { - navigateToLesson(parsed.moduleId, parsed.lessonIndex, false); + handleRoute(false); +} + +/** + * Main route handler - switches between page types + */ +function handleRoute(shouldUpdateUrl = true) { + const route = parseHash(); + + if (!route) { + // Invalid route - go to home + navigateTo(""); + showLandingPage(); + return; } + + switch (route.type) { + case RouteType.HOME: + showLandingPage(); + break; + case RouteType.SECTION: + showSectionPage(route.sectionId); + break; + case RouteType.REFERENCE: + // Reference pages - TODO: implement later + showLandingPage(); + break; + case RouteType.LESSON: + navigateToLesson(route.moduleId, route.lessonIndex, shouldUpdateUrl); + break; + default: + showLandingPage(); + } + + updateNavHighlight(route); +} + +/** + * Hide all page containers + */ +function hideAllPages() { + elements.landingPage?.classList.add("hidden"); + elements.sectionPage?.classList.add("hidden"); + elements.gameLayout?.classList.add("hidden"); +} + +/** + * Show home landing page + */ +function showLandingPage() { + hideAllPages(); + elements.landingPage?.classList.remove("hidden"); + + // Update section progress on landing page + updateLandingProgress(); +} + +/** + * Update progress indicators on landing page + */ +function updateLandingProgress() { + ["css", "html", "tailwind"].forEach((sectionId) => { + const progressEl = document.getElementById(`${sectionId}-progress`); + if (progressEl) { + const sectionModules = getModulesBySection(lessonEngine.modules, sectionId); + let completed = 0; + let total = 0; + sectionModules.forEach((m) => { + total += m.lessons.length; + const progress = lessonEngine.userProgress[m.id]; + if (progress?.completed) { + completed += progress.completed.length; + } + }); + if (total > 0) { + progressEl.textContent = `${completed}/${total} lessons`; + } else { + progressEl.textContent = ""; + } + } + }); +} + +/** + * Show section landing page + */ +function showSectionPage(sectionId) { + hideAllPages(); + elements.sectionPage?.classList.remove("hidden"); + + const section = getSection(sectionId); + if (!section) { + showLandingPage(); + return; + } + + // Update section header + if (elements.sectionTitle) elements.sectionTitle.textContent = section.title; + if (elements.sectionDescription) elements.sectionDescription.textContent = section.description; + + // Get modules for this section + const sectionModules = getModulesBySection(lessonEngine.modules, sectionId); + + // Calculate section progress + let completed = 0; + let total = 0; + sectionModules.forEach((m) => { + total += m.lessons.length; + const progress = lessonEngine.userProgress[m.id]; + if (progress?.completed) { + completed += progress.completed.length; + } + }); + + // Update progress bar + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + if (elements.sectionProgressFill) elements.sectionProgressFill.style.width = `${percent}%`; + if (elements.sectionProgressText) elements.sectionProgressText.textContent = `${completed} of ${total} lessons complete`; + + // Render module cards + renderModuleGrid(sectionModules, sectionId); +} + +/** + * Render module cards in section page + */ +function renderModuleGrid(modules, sectionId) { + if (!elements.moduleGrid) return; + + elements.moduleGrid.innerHTML = ""; + + modules.forEach((module) => { + const card = document.createElement("a"); + card.href = `#${module.id}/0`; + card.className = "module-card"; + + // Calculate module progress + const progress = lessonEngine.userProgress[module.id]; + const completed = progress?.completed?.length || 0; + const total = module.lessons.length; + + card.innerHTML = ` +

${module.title}

+
+ ${total} lessons + ${completed > 0 ? `${completed}/${total}` : ""} +
+ `; + + elements.moduleGrid.appendChild(card); + }); +} + +/** + * Show lesson UI (game layout) + */ +function showLessonUI() { + hideAllPages(); + elements.gameLayout?.classList.remove("hidden"); +} + +/** + * Update nav link highlighting + */ +function updateNavHighlight(route) { + if (!elements.mainNav) return; + + const navLinks = elements.mainNav.querySelectorAll(".nav-link"); + navLinks.forEach((link) => { + link.classList.remove("active"); + + if (route?.type === RouteType.SECTION && link.dataset.section === route.sectionId) { + link.classList.add("active"); + } else if (route?.type === RouteType.LESSON) { + // Highlight section based on module's inferred section + const module = lessonEngine.modules.find((m) => m.id === route.moduleId); + if (module) { + const moduleSection = getModuleSection(module); + if (link.dataset.section === moduleSection) { + link.classList.add("active"); + } + } + } + }); } function navigateToLesson(moduleId, lessonIndex, shouldUpdateUrl = true) { + // Show lesson UI + showLessonUI(); + // Validate moduleId exists const module = lessonEngine.modules.find((m) => m.id === moduleId); if (!module) { @@ -915,13 +1103,11 @@ function init() { elements.closeSidebar.addEventListener("click", closeSidebar); elements.sidebarBackdrop.addEventListener("click", closeSidebar); - // Logo click - navigate to welcome + // Logo click - navigate to home landing elements.logoLink.addEventListener("click", (e) => { e.preventDefault(); - lessonEngine.setModuleById("welcome"); - updateHash("welcome", 0); - loadCurrentLesson(); - updateModuleHighlight("welcome"); + navigateTo(""); + showLandingPage(); }); // Language select diff --git a/src/config/sections.js b/src/config/sections.js new file mode 100644 index 0000000..2082c6f --- /dev/null +++ b/src/config/sections.js @@ -0,0 +1,75 @@ +/** + * Section definitions for Code Crispies + * Sections group related modules (CSS, HTML, Tailwind) + */ + +export const sections = { + css: { + id: "css", + title: "CSS", + description: "Styling, layout, and animations", + color: "#264de4", + order: 1 + }, + html: { + id: "html", + title: "HTML", + description: "Semantic markup and native elements", + color: "#e34c26", + order: 2 + }, + tailwind: { + id: "tailwind", + title: "Tailwind CSS", + description: "Utility-first CSS framework", + color: "#06b6d4", + order: 3 + } +}; + +/** + * Get section by ID + * @param {string} sectionId + * @returns {object|null} + */ +export function getSection(sectionId) { + return sections[sectionId] || null; +} + +/** + * Get all sections sorted by order + * @returns {object[]} + */ +export function getSectionList() { + return Object.values(sections).sort((a, b) => a.order - b.order); +} + +/** + * Infer section from module mode + * @param {object} module + * @returns {string} + */ +export function getModuleSection(module) { + // Explicit section takes precedence + if (module.section) return module.section; + + // Infer from mode + const mode = module.mode || "css"; + if (mode === "html") return "html"; + if (mode === "tailwind") return "tailwind"; + return "css"; +} + +/** + * Filter modules by section + * @param {object[]} modules + * @param {string} sectionId + * @returns {object[]} + */ +export function getModulesBySection(modules, sectionId) { + return modules.filter((m) => { + // Skip excluded modules (welcome, goodbye) + if (m.excludeFromProgress) return false; + return getModuleSection(m) === sectionId; + }); +} diff --git a/src/helpers/router.js b/src/helpers/router.js index 9806016..2dfae9e 100644 --- a/src/helpers/router.js +++ b/src/helpers/router.js @@ -1,26 +1,81 @@ /** * URL Router for Code Crispies - * Handles hash-based routing for shareable lesson links - * Format: #module-id/lesson-index (e.g., #flexbox/2) + * Handles hash-based routing for pages, sections, and lessons + * + * Route formats: + * - # -> Home landing page + * - #css -> CSS section landing + * - #html -> HTML section landing + * - #tailwind -> Tailwind section landing + * - #reference/css -> CSS cheatsheet + * - #module/index -> Lesson (e.g., #flexbox/2) */ /** - * Parse current URL hash into module and lesson info - * @returns {{ moduleId: string, lessonIndex: number } | null} + * Route types + */ +export const RouteType = { + HOME: "home", + SECTION: "section", + REFERENCE: "reference", + LESSON: "lesson" +}; + +/** + * Valid section IDs + */ +const SECTIONS = ["css", "html", "tailwind"]; + +/** + * Parse current URL hash into route info + * @returns {{ type: string, moduleId?: string, lessonIndex?: number, sectionId?: string, refId?: string } | null} */ export function parseHash() { const hash = window.location.hash.slice(1); // Remove '#' - if (!hash) return null; + + // Empty hash = home + if (!hash) { + return { type: RouteType.HOME }; + } const parts = hash.split("/"); - if (parts.length !== 2) return null; - const moduleId = parts[0]; - const lessonIndex = parseInt(parts[1], 10); + // Single segment routes + if (parts.length === 1) { + const segment = parts[0]; - if (!moduleId || isNaN(lessonIndex) || lessonIndex < 0) return null; + // Section landing pages + if (SECTIONS.includes(segment)) { + return { type: RouteType.SECTION, sectionId: segment }; + } - return { moduleId, lessonIndex }; + // Reference index (no specific ref) + if (segment === "reference") { + return { type: RouteType.REFERENCE, refId: null }; + } + + // Single segment could be module with implicit lesson 0 + return { type: RouteType.LESSON, moduleId: segment, lessonIndex: 0 }; + } + + // Two segment routes + if (parts.length === 2) { + // Reference subpages + if (parts[0] === "reference") { + return { type: RouteType.REFERENCE, refId: parts[1] }; + } + + // Lesson route (existing behavior) + const moduleId = parts[0]; + const lessonIndex = parseInt(parts[1], 10); + + if (moduleId && !isNaN(lessonIndex) && lessonIndex >= 0) { + return { type: RouteType.LESSON, moduleId, lessonIndex }; + } + } + + // Invalid route + return null; } /** @@ -35,6 +90,17 @@ export function updateHash(moduleId, lessonIndex) { } } +/** + * Update URL to a specific route + * @param {string} route - The route string (e.g., "css", "reference/flexbox", "") + */ +export function navigateTo(route) { + const newHash = route ? `#${route}` : "#"; + if (window.location.hash !== newHash) { + history.pushState(null, "", newHash); + } +} + /** * Replace URL hash without history entry (for invalid URL fallbacks) * @param {string} moduleId @@ -45,6 +111,15 @@ export function replaceHash(moduleId, lessonIndex) { history.replaceState(null, "", newHash); } +/** + * Replace URL to a specific route without history entry + * @param {string} route - The route string + */ +export function replaceTo(route) { + const newHash = route ? `#${route}` : "#"; + history.replaceState(null, "", newHash); +} + /** * Build full shareable URL for current lesson * @param {string} moduleId @@ -55,3 +130,11 @@ export function getShareableUrl(moduleId, lessonIndex) { const base = window.location.origin + window.location.pathname; return `${base}#${moduleId}/${lessonIndex}`; } + +/** + * Get valid section IDs + * @returns {string[]} + */ +export function getSectionIds() { + return [...SECTIONS]; +} diff --git a/src/impl/LessonEngine.js b/src/impl/LessonEngine.js index 9f90997..3c6c53f 100644 --- a/src/impl/LessonEngine.js +++ b/src/impl/LessonEngine.js @@ -463,9 +463,12 @@ export class LessonEngine { 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 && progress.completed) { + if (progress?.completed) { totalCompleted += progress.completed.length; } }); diff --git a/src/index.html b/src/index.html index a5d91ea..1a2a7d5 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,11 @@ + + + + +
@@ -37,9 +91,19 @@

diff --git a/src/main.css b/src/main.css index df7675a..c353f47 100644 --- a/src/main.css +++ b/src/main.css @@ -384,7 +384,9 @@ kbd { cursor: pointer; color: var(--light-text); border-radius: var(--border-radius-sm); - transition: color 0.2s, background 0.2s; + transition: + color 0.2s, + background 0.2s; } .share-btn:hover { @@ -1481,6 +1483,223 @@ input:checked + .toggle-slider::before { text-decoration: none; } +/* ================= MAIN NAV ================= */ +.main-nav { + display: none; + align-items: center; + gap: var(--spacing-xs); +} + +.nav-link { + padding: 6px 12px; + border-radius: var(--border-radius-sm); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + color: var(--light-text); + transition: + background 0.2s, + color 0.2s; +} + +.nav-link:hover { + background: var(--primary-bg-light); + color: var(--text-color); +} + +.nav-link.active { + background: var(--primary-bg-medium); + color: var(--primary-color); +} + +@media (min-width: 769px) { + .main-nav { + display: flex; + } +} + +/* ================= LANDING PAGE ================= */ +.landing-page { + flex: 1; + overflow-y: auto; + background: var(--bg-color); +} + +.landing-content { + max-width: 900px; + margin: 0 auto; + padding: var(--spacing-lg); +} + +.hero { + text-align: center; + padding: 3rem 1rem; +} + +.hero-logo { + margin-bottom: 1rem; +} + +.hero h1 { + font-size: 2.5rem; + font-weight: 800; + color: var(--text-color); + line-height: 1.2; + margin-bottom: 1rem; +} + +.hero-highlight { + color: var(--primary-color); +} + +.hero-subtitle { + font-size: 1.1rem; + color: var(--light-text); + max-width: 500px; + margin: 0 auto; +} + +/* Section Cards */ +.section-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--spacing-lg); + margin-top: 2rem; +} + +.section-card { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--spacing-lg); + background: var(--panel-bg); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow); + text-decoration: none; + color: var(--text-color); + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.section-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); +} + +.section-card-icon { + width: 60px; + height: 60px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 1.2rem; + color: white; + margin-bottom: var(--spacing-md); +} + +.section-card h2 { + font-size: 1.25rem; + margin-bottom: var(--spacing-xs); +} + +.section-card p { + font-size: 0.9rem; + color: var(--light-text); + text-align: center; + margin-bottom: var(--spacing-sm); +} + +.section-card-progress { + font-size: 0.8rem; + color: var(--primary-color); + font-weight: 500; +} + +/* ================= SECTION PAGE ================= */ +.section-page { + flex: 1; + overflow-y: auto; + background: var(--bg-color); + padding: var(--spacing-lg); +} + +.section-hero { + text-align: center; + padding: 2rem 1rem; + max-width: 600px; + margin: 0 auto; +} + +.section-hero h1 { + font-size: 2rem; + color: var(--primary-dark); + margin-bottom: var(--spacing-xs); +} + +.section-hero p { + color: var(--light-text); + margin-bottom: var(--spacing-md); +} + +.section-progress-bar { + max-width: 400px; + margin: 0 auto; +} + +.section-progress-bar .progress-bar { + height: 8px; + margin-bottom: var(--spacing-xs); +} + +/* Module Grid */ +.module-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--spacing-md); + max-width: 1000px; + margin: 0 auto; +} + +.module-card { + display: block; + padding: var(--spacing-md); + background: var(--panel-bg); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow); + text-decoration: none; + color: var(--text-color); + border-left: 4px solid var(--primary-color); + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.module-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.module-card h3 { + font-size: 1rem; + color: var(--primary-dark); + margin-bottom: var(--spacing-xs); +} + +.module-card-meta { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--light-text); +} + +.module-card-progress { + color: var(--success-color); + font-weight: 500; +} + /* ================= UTILITY ================= */ .hidden { display: none !important;