feat: add landing pages and section navigation
- Add home landing page with section cards (CSS, HTML, Tailwind) - Add section landing pages with module grid and progress tracking - Implement extended URL routing for pages, sections, and lessons - Create sections.js configuration for module categorization - Exclude welcome/goodbye modules from progress stats - Add main navigation links in header (desktop only) - Update logo click to navigate to home landing Routes: - # → Home landing - #css, #html, #tailwind → Section landing pages - #module/index → Lesson (unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"description": "Welcome to Code Crispies - your interactive web development learning platform",
|
"description": "Welcome to Code Crispies - your interactive web development learning platform",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Congratulations on completing your learning journey!",
|
"description": "Congratulations on completing your learning journey!",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "congratulations",
|
"id": "congratulations",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "مرحباً بك في Code Crispies - منصتك التفاعلية لتعلم تطوير الويب",
|
"description": "مرحباً بك في Code Crispies - منصتك التفاعلية لتعلم تطوير الويب",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Willkommen bei Code Crispies - deine interaktive Lernplattform für Webentwicklung",
|
"description": "Willkommen bei Code Crispies - deine interaktive Lernplattform für Webentwicklung",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Bienvenido a Code Crispies - tu plataforma interactiva de aprendizaje de desarrollo web",
|
"description": "Bienvenido a Code Crispies - tu plataforma interactiva de aprendizaje de desarrollo web",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Witaj w Code Crispies - twojej interaktywnej platformie do nauki tworzenia stron",
|
"description": "Witaj w Code Crispies - twojej interaktywnej platformie do nauki tworzenia stron",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Ласкаво просимо до Code Crispies - вашої інтерактивної платформи для вивчення веб-розробки",
|
"description": "Ласкаво просимо до Code Crispies - вашої інтерактивної платформи для вивчення веб-розробки",
|
||||||
"mode": "html",
|
"mode": "html",
|
||||||
"difficulty": "beginner",
|
"difficulty": "beginner",
|
||||||
|
"excludeFromProgress": true,
|
||||||
"lessons": [
|
"lessons": [
|
||||||
{
|
{
|
||||||
"id": "get-started",
|
"id": "get-started",
|
||||||
|
|||||||
240
src/app.js
240
src/app.js
@@ -3,7 +3,8 @@ import { CodeEditor } from "./impl/CodeEditor.js";
|
|||||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } 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 } 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
|
// Simplified state - LessonEngine now manages lesson state and progress
|
||||||
const state = {
|
const state = {
|
||||||
@@ -22,6 +23,19 @@ const elements = {
|
|||||||
logoLink: document.getElementById("logo-link"),
|
logoLink: document.getElementById("logo-link"),
|
||||||
langSelect: document.getElementById("lang-select"),
|
langSelect: document.getElementById("lang-select"),
|
||||||
helpBtn: document.getElementById("help-btn"),
|
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
|
// Left panel
|
||||||
instructionsSection: document.querySelector(".instructions"),
|
instructionsSection: document.querySelector(".instructions"),
|
||||||
@@ -293,23 +307,8 @@ function initializeModules() {
|
|||||||
// Use the new renderModuleList function with both callbacks
|
// Use the new renderModuleList function with both callbacks
|
||||||
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
|
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
|
||||||
|
|
||||||
// Check URL first for shareable links
|
// Handle route (home, section, or lesson)
|
||||||
const urlState = parseHash();
|
handleRoute(false);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProgressDisplay();
|
updateProgressDisplay();
|
||||||
clearLoadingTimeout();
|
clearLoadingTimeout();
|
||||||
@@ -325,6 +324,9 @@ function selectModule(moduleId) {
|
|||||||
const success = lessonEngine.setModuleById(moduleId);
|
const success = lessonEngine.setModuleById(moduleId);
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
|
|
||||||
|
// Show lesson UI
|
||||||
|
showLessonUI();
|
||||||
|
|
||||||
// Update URL
|
// Update URL
|
||||||
const engineState = lessonEngine.getCurrentState();
|
const engineState = lessonEngine.getCurrentState();
|
||||||
updateHash(moduleId, engineState.lessonIndex);
|
updateHash(moduleId, engineState.lessonIndex);
|
||||||
@@ -355,6 +357,9 @@ function selectLesson(moduleId, lessonIndex) {
|
|||||||
|
|
||||||
lessonEngine.setLessonByIndex(lessonIndex);
|
lessonEngine.setLessonByIndex(lessonIndex);
|
||||||
|
|
||||||
|
// Show lesson UI
|
||||||
|
showLessonUI();
|
||||||
|
|
||||||
// Update URL
|
// Update URL
|
||||||
updateHash(moduleId, lessonIndex);
|
updateHash(moduleId, lessonIndex);
|
||||||
|
|
||||||
@@ -814,7 +819,7 @@ async function copyShareUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= URL ROUTING =================
|
// ================= URL ROUTING & PAGE SWITCHING =================
|
||||||
|
|
||||||
function initRouter() {
|
function initRouter() {
|
||||||
// Handle browser back/forward
|
// Handle browser back/forward
|
||||||
@@ -822,13 +827,196 @@ function initRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePopState() {
|
function handlePopState() {
|
||||||
const parsed = parseHash();
|
handleRoute(false);
|
||||||
if (parsed) {
|
}
|
||||||
navigateToLesson(parsed.moduleId, parsed.lessonIndex, 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 = `
|
||||||
|
<h3>${module.title}</h3>
|
||||||
|
<div class="module-card-meta">
|
||||||
|
<span>${total} lessons</span>
|
||||||
|
<span class="module-card-progress">${completed > 0 ? `${completed}/${total}` : ""}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) {
|
function navigateToLesson(moduleId, lessonIndex, shouldUpdateUrl = true) {
|
||||||
|
// Show lesson UI
|
||||||
|
showLessonUI();
|
||||||
|
|
||||||
// Validate moduleId exists
|
// Validate moduleId exists
|
||||||
const module = lessonEngine.modules.find((m) => m.id === moduleId);
|
const module = lessonEngine.modules.find((m) => m.id === moduleId);
|
||||||
if (!module) {
|
if (!module) {
|
||||||
@@ -915,13 +1103,11 @@ function init() {
|
|||||||
elements.closeSidebar.addEventListener("click", closeSidebar);
|
elements.closeSidebar.addEventListener("click", closeSidebar);
|
||||||
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
|
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
|
||||||
|
|
||||||
// Logo click - navigate to welcome
|
// Logo click - navigate to home landing
|
||||||
elements.logoLink.addEventListener("click", (e) => {
|
elements.logoLink.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lessonEngine.setModuleById("welcome");
|
navigateTo("");
|
||||||
updateHash("welcome", 0);
|
showLandingPage();
|
||||||
loadCurrentLesson();
|
|
||||||
updateModuleHighlight("welcome");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Language select
|
// Language select
|
||||||
|
|||||||
75
src/config/sections.js
Normal file
75
src/config/sections.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,26 +1,81 @@
|
|||||||
/**
|
/**
|
||||||
* URL Router for Code Crispies
|
* URL Router for Code Crispies
|
||||||
* Handles hash-based routing for shareable lesson links
|
* Handles hash-based routing for pages, sections, and lessons
|
||||||
* Format: #module-id/lesson-index (e.g., #flexbox/2)
|
*
|
||||||
|
* 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
|
* Route types
|
||||||
* @returns {{ moduleId: string, lessonIndex: number } | null}
|
*/
|
||||||
|
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() {
|
export function parseHash() {
|
||||||
const hash = window.location.hash.slice(1); // Remove '#'
|
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("/");
|
const parts = hash.split("/");
|
||||||
if (parts.length !== 2) return null;
|
|
||||||
|
|
||||||
|
// Single segment routes
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const segment = parts[0];
|
||||||
|
|
||||||
|
// Section landing pages
|
||||||
|
if (SECTIONS.includes(segment)) {
|
||||||
|
return { type: RouteType.SECTION, sectionId: segment };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 moduleId = parts[0];
|
||||||
const lessonIndex = parseInt(parts[1], 10);
|
const lessonIndex = parseInt(parts[1], 10);
|
||||||
|
|
||||||
if (!moduleId || isNaN(lessonIndex) || lessonIndex < 0) return null;
|
if (moduleId && !isNaN(lessonIndex) && lessonIndex >= 0) {
|
||||||
|
return { type: RouteType.LESSON, moduleId, lessonIndex };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { 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)
|
* Replace URL hash without history entry (for invalid URL fallbacks)
|
||||||
* @param {string} moduleId
|
* @param {string} moduleId
|
||||||
@@ -45,6 +111,15 @@ export function replaceHash(moduleId, lessonIndex) {
|
|||||||
history.replaceState(null, "", newHash);
|
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
|
* Build full shareable URL for current lesson
|
||||||
* @param {string} moduleId
|
* @param {string} moduleId
|
||||||
@@ -55,3 +130,11 @@ export function getShareableUrl(moduleId, lessonIndex) {
|
|||||||
const base = window.location.origin + window.location.pathname;
|
const base = window.location.origin + window.location.pathname;
|
||||||
return `${base}#${moduleId}/${lessonIndex}`;
|
return `${base}#${moduleId}/${lessonIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid section IDs
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getSectionIds() {
|
||||||
|
return [...SECTIONS];
|
||||||
|
}
|
||||||
|
|||||||
@@ -463,9 +463,12 @@ export class LessonEngine {
|
|||||||
let totalCompleted = 0;
|
let totalCompleted = 0;
|
||||||
|
|
||||||
this.modules.forEach((module) => {
|
this.modules.forEach((module) => {
|
||||||
|
// Skip modules excluded from progress (e.g., welcome, goodbye)
|
||||||
|
if (module.excludeFromProgress) return;
|
||||||
|
|
||||||
totalLessons += module.lessons.length;
|
totalLessons += module.lessons.length;
|
||||||
const progress = this.userProgress[module.id];
|
const progress = this.userProgress[module.id];
|
||||||
if (progress && progress.completed) {
|
if (progress?.completed) {
|
||||||
totalCompleted += progress.completed.length;
|
totalCompleted += progress.completed.length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
<button id="menu-btn" class="menu-toggle" data-i18n-aria-label="menuOpen" aria-label="Open menu">
|
<button id="menu-btn" class="menu-toggle" data-i18n-aria-label="menuOpen" aria-label="Open menu">
|
||||||
<span class="hamburger-icon"></span>
|
<span class="hamburger-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<nav class="main-nav" id="main-nav" aria-label="Main sections">
|
||||||
|
<a href="#css" class="nav-link" data-section="css">CSS</a>
|
||||||
|
<a href="#html" class="nav-link" data-section="html">HTML</a>
|
||||||
|
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a>
|
||||||
|
</nav>
|
||||||
<span class="header-level-pill" id="header-level-pill"></span>
|
<span class="header-level-pill" id="header-level-pill"></span>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" id="logo-link" class="logo">
|
<a href="#" id="logo-link" class="logo">
|
||||||
@@ -30,6 +35,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Landing Page (Home) -->
|
||||||
|
<div class="landing-page hidden" id="landing-page">
|
||||||
|
<div class="landing-content">
|
||||||
|
<section class="hero">
|
||||||
|
<img src="./bowl.png" width="120" alt="" class="hero-logo" />
|
||||||
|
<h1>Learn Web Development<br /><span class="hero-highlight">Interactively</span></h1>
|
||||||
|
<p class="hero-subtitle">Master HTML, CSS, and Tailwind through hands-on exercises. Free and open source.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-cards" id="section-cards">
|
||||||
|
<a href="#css" class="section-card" data-section="css">
|
||||||
|
<div class="section-card-icon" style="background: #264de4">CSS</div>
|
||||||
|
<h2>CSS</h2>
|
||||||
|
<p>Styling, layout, and animations</p>
|
||||||
|
<span class="section-card-progress" id="css-progress"></span>
|
||||||
|
</a>
|
||||||
|
<a href="#html" class="section-card" data-section="html">
|
||||||
|
<div class="section-card-icon" style="background: #e34c26">HTML</div>
|
||||||
|
<h2>HTML</h2>
|
||||||
|
<p>Semantic markup and native elements</p>
|
||||||
|
<span class="section-card-progress" id="html-progress"></span>
|
||||||
|
</a>
|
||||||
|
<a href="#tailwind" class="section-card" data-section="tailwind">
|
||||||
|
<div class="section-card-icon" style="background: #06b6d4">TW</div>
|
||||||
|
<h2>Tailwind CSS</h2>
|
||||||
|
<p>Utility-first CSS framework</p>
|
||||||
|
<span class="section-card-progress" id="tailwind-progress"></span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Landing Page -->
|
||||||
|
<div class="section-page hidden" id="section-page">
|
||||||
|
<header class="section-hero">
|
||||||
|
<h1 id="section-title">CSS</h1>
|
||||||
|
<p id="section-description">Styling, layout, and animations</p>
|
||||||
|
<div class="section-progress-bar">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="section-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text" id="section-progress-text">0 of 0 lessons complete</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="module-grid" id="module-grid">
|
||||||
|
<!-- Module cards populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="game-layout" id="main-content">
|
<main class="game-layout" id="main-content">
|
||||||
<!-- Left Panel: Instructions + Editor -->
|
<!-- Left Panel: Instructions + Editor -->
|
||||||
<div class="left-panel">
|
<div class="left-panel">
|
||||||
@@ -37,9 +91,19 @@
|
|||||||
<div class="lesson-title-row">
|
<div class="lesson-title-row">
|
||||||
<h2 id="lesson-title"></h2>
|
<h2 id="lesson-title"></h2>
|
||||||
<button id="share-btn" class="share-btn" data-i18n-title="shareTitle" title="Share lesson" aria-label="Share lesson">
|
<button id="share-btn" class="share-btn" data-i18n-title="shareTitle" title="Share lesson" aria-label="Share lesson">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
221
src/main.css
221
src/main.css
@@ -384,7 +384,9 @@ kbd {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--light-text);
|
color: var(--light-text);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
transition: color 0.2s, background 0.2s;
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn:hover {
|
.share-btn:hover {
|
||||||
@@ -1481,6 +1483,223 @@ input:checked + .toggle-slider::before {
|
|||||||
text-decoration: none;
|
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 ================= */
|
/* ================= UTILITY ================= */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user