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:
2026-01-14 23:15:34 +01:00
parent 5083032735
commit 165ed3d73f
13 changed files with 679 additions and 42 deletions

View File

@@ -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 = `
<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) {
// 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

75
src/config/sections.js Normal file
View 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;
});
}

View File

@@ -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];
}

View File

@@ -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;
}
});

View File

@@ -19,6 +19,11 @@
<button id="menu-btn" class="menu-toggle" data-i18n-aria-label="menuOpen" aria-label="Open menu">
<span class="hamburger-icon"></span>
</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>
</div>
<a href="#" id="logo-link" class="logo">
@@ -30,6 +35,55 @@
</div>
</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">
<!-- Left Panel: Instructions + Editor -->
<div class="left-panel">
@@ -37,9 +91,19 @@
<div class="lesson-title-row">
<h2 id="lesson-title"></h2>
<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">
<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
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"
>
<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>
</button>
</div>

View File

@@ -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;