Files
code-crispies/src/app.js
Michael Czechowski f4181d6ada feat: close dialogs on backdrop click
Native dialog elements don't close on backdrop click by default.
Added click handlers that check if click target is the dialog itself
(not its children) to enable this expected UX behavior.
2025-12-30 14:54:49 +01:00

621 lines
18 KiB
JavaScript

import { LessonEngine } from "./impl/LessonEngine.js";
import { CodeEditor } from "./impl/CodeEditor.js";
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
import { loadModules } from "./config/lessons.js";
// Simplified state - LessonEngine now manages lesson state and progress
const state = {
userSettings: {
disableFeedbackErrors: false
},
showExpected: false
};
// DOM elements - updated for new layout
const elements = {
// Header
menuBtn: document.getElementById("menu-btn"),
helpBtn: document.getElementById("help-btn"),
// Left panel
modulePill: document.getElementById("module-pill"),
lessonTitle: document.getElementById("lesson-title"),
lessonDescription: document.getElementById("lesson-description"),
taskInstruction: document.getElementById("task-instruction"),
codeInput: document.getElementById("code-input"),
runBtn: document.getElementById("run-btn"),
undoBtn: document.getElementById("undo-btn"),
redoBtn: document.getElementById("redo-btn"),
resetCodeBtn: document.getElementById("reset-code-btn"),
hintArea: document.getElementById("hint-area"),
editorContent: document.querySelector(".editor-content"),
codeEditor: document.querySelector(".code-editor"),
// Right panel
previewArea: document.getElementById("preview-area"),
showExpectedBtn: document.getElementById("show-expected-btn"),
expectedOverlay: document.getElementById("expected-overlay"),
previewWrapper: document.querySelector(".preview-wrapper"),
prevBtn: document.getElementById("prev-btn"),
nextBtn: document.getElementById("next-btn"),
levelIndicator: document.getElementById("level-indicator"),
// Sidebar
sidebarDrawer: document.getElementById("sidebar-drawer"),
sidebarBackdrop: document.getElementById("sidebar-backdrop"),
closeSidebar: document.getElementById("close-sidebar"),
moduleList: document.getElementById("module-list"),
progressFill: document.getElementById("progress-fill"),
progressText: document.getElementById("progress-text"),
resetBtn: document.getElementById("reset-btn"),
disableFeedbackToggle: document.getElementById("disable-feedback-toggle"),
// Dialogs
helpDialog: document.getElementById("help-dialog"),
helpDialogClose: document.getElementById("help-dialog-close"),
resetDialog: document.getElementById("reset-dialog"),
resetDialogClose: document.getElementById("reset-dialog-close"),
cancelReset: document.getElementById("cancel-reset"),
confirmReset: document.getElementById("confirm-reset")
};
// Initialize the lesson engine - now the single source of truth
const lessonEngine = new LessonEngine();
// Code editor instance (initialized later)
let codeEditor = null;
let currentMode = "css";
// ================= SIDEBAR FUNCTIONS =================
// Track element that opened sidebar for focus return
let sidebarTrigger = null;
function openSidebar() {
// Store trigger element for focus return
sidebarTrigger = document.activeElement;
elements.sidebarDrawer.classList.add("open");
elements.sidebarBackdrop.classList.add("visible");
// Move focus to close button for keyboard users
elements.closeSidebar.focus();
}
function closeSidebar() {
elements.sidebarDrawer.classList.remove("open");
elements.sidebarBackdrop.classList.remove("visible");
// Return focus to trigger element
if (sidebarTrigger && typeof sidebarTrigger.focus === "function") {
sidebarTrigger.focus();
sidebarTrigger = null;
}
}
// ================= EXPECTED RESULT TOGGLE =================
function toggleExpectedResult() {
state.showExpected = !state.showExpected;
if (state.showExpected) {
elements.expectedOverlay.classList.add("visible");
elements.showExpectedBtn.textContent = "Hide Expected";
elements.showExpectedBtn.classList.add("btn-primary");
} else {
elements.expectedOverlay.classList.remove("visible");
elements.showExpectedBtn.textContent = "Show Expected";
elements.showExpectedBtn.classList.remove("btn-primary");
}
}
// ================= HINT SYSTEM =================
function showHint(message, step, total, isSuccess = false) {
const hintClass = isSuccess ? "hint hint-success" : "hint";
elements.hintArea.innerHTML = `
<div class="${hintClass}">
<span class="hint-progress">${step}/${total}</span>
<span class="hint-message">${message}</span>
</div>
`;
}
function clearHint() {
elements.hintArea.innerHTML = "";
}
function showSuccessHint(message) {
elements.hintArea.innerHTML = `
<div class="hint hint-success">
<span class="hint-progress">✓</span>
<span class="hint-message">${message}</span>
</div>
`;
}
// ================= PROGRESS DISPLAY =================
function updateProgressDisplay() {
const stats = lessonEngine.getProgressStats();
elements.progressFill.style.width = `${stats.percentComplete}%`;
elements.progressText.textContent = `${stats.percentComplete}% Complete (${stats.totalCompleted}/${stats.totalLessons})`;
}
// ================= USER SETTINGS =================
function loadUserSettings() {
const savedSettings = localStorage.getItem("codeCrispies.settings");
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
state.userSettings = { ...state.userSettings, ...settings };
elements.disableFeedbackToggle.checked = !state.userSettings.disableFeedbackErrors;
} catch (e) {
console.error("Error loading user settings:", e);
}
}
}
function saveUserSettings() {
localStorage.setItem("codeCrispies.settings", JSON.stringify(state.userSettings));
}
// ================= MODULE INITIALIZATION =================
async function initializeModules() {
try {
const modules = await loadModules();
lessonEngine.setModules(modules);
// Use the new renderModuleList function with both callbacks
renderModuleList(elements.moduleList, modules, selectModule, selectLesson);
// Load saved progress and select appropriate module
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();
} catch (error) {
console.error("Failed to load modules:", error);
elements.lessonDescription.textContent = "Failed to load modules. Please refresh the page.";
}
}
// ================= MODULE/LESSON SELECTION =================
function selectModule(moduleId) {
const success = lessonEngine.setModuleById(moduleId);
if (!success) return;
// Update module list UI to highlight the active module
const moduleItems = elements.moduleList.querySelectorAll(".module-header");
moduleItems.forEach((item) => {
item.classList.remove("active");
if (item.dataset.moduleId === moduleId) {
item.classList.add("active");
}
});
loadCurrentLesson();
resetSuccessIndicators();
// Close sidebar after selection on mobile
if (window.innerWidth <= 768) {
closeSidebar();
}
}
function selectLesson(moduleId, lessonIndex) {
const currentState = lessonEngine.getCurrentState();
if (!currentState.module || currentState.module.id !== moduleId) {
lessonEngine.setModuleById(moduleId);
}
lessonEngine.setLessonByIndex(lessonIndex);
loadCurrentLesson();
// Close sidebar after selection on mobile
if (window.innerWidth <= 768) {
closeSidebar();
}
}
// ================= LESSON LOADING =================
function resetSuccessIndicators() {
elements.codeEditor.classList.remove("success-highlight");
elements.lessonTitle.classList.remove("success-text");
elements.nextBtn.classList.remove("success");
elements.taskInstruction.classList.remove("success-instruction");
elements.runBtn.classList.remove("success");
elements.previewWrapper?.classList.remove("matched");
}
function updateEditorForMode(mode) {
const editorLabel = document.querySelector(".editor-label");
const modeConfig = {
html: {
placeholder: "Type HTML here... Try: nav>ul>li*3 then press Tab",
label: "HTML Editor",
cmMode: "html"
},
tailwind: {
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
label: "Tailwind Classes",
cmMode: "css"
},
css: {
placeholder: "Enter your CSS code here...",
label: "CSS Editor",
cmMode: "css"
}
};
const config = modeConfig[mode] || modeConfig.css;
if (editorLabel) editorLabel.textContent = config.label;
// Update CodeMirror mode if needed
if (codeEditor && currentMode !== config.cmMode) {
currentMode = config.cmMode;
codeEditor.setMode(config.cmMode);
}
}
function loadCurrentLesson() {
const engineState = lessonEngine.getCurrentState();
if (!engineState.module || !engineState.lesson) {
return;
}
const lesson = engineState.lesson;
const mode = lesson.mode || engineState.module?.mode || "css";
// Update UI based on mode
updateEditorForMode(mode);
// Update module pill with category name
if (elements.modulePill && engineState.module) {
elements.modulePill.textContent = engineState.module.title;
}
// Reset any success indicators
resetSuccessIndicators();
// Clear hints
clearHint();
// Hide expected overlay
state.showExpected = false;
elements.expectedOverlay.classList.remove("visible");
elements.showExpectedBtn.textContent = "Show Expected";
elements.showExpectedBtn.classList.remove("btn-primary");
// Update UI
renderLesson(
elements.lessonTitle,
elements.lessonDescription,
elements.taskInstruction,
elements.previewArea,
null, // editorPrefix no longer used
null, // codeInput no longer used (using CodeMirror)
null, // editorSuffix no longer used
lesson
);
// Set user code in CodeMirror
if (codeEditor) {
codeEditor.setValue(engineState.userCode);
}
// Update Run button text based on completion status
if (engineState.isCompleted) {
elements.runBtn.innerHTML = '<img src="./gear.svg" alt="" />Re-run';
// Add completion badge if not present
if (!document.querySelector(".completion-badge")) {
const badge = document.createElement("span");
badge.className = "completion-badge";
badge.textContent = "Completed";
elements.lessonTitle.appendChild(badge);
}
} else {
elements.runBtn.innerHTML = '<img src="./gear.svg" alt="" />Run';
// Remove completion badge if exists
const badge = document.querySelector(".completion-badge");
if (badge) badge.remove();
}
// Update level indicator
renderLevelIndicator(elements.levelIndicator, engineState.lessonIndex + 1, engineState.totalLessons);
// Update active lesson in sidebar
updateActiveLessonInSidebar(engineState.module.id, engineState.lessonIndex);
// Update navigation buttons
updateNavigationButtons();
// Update progress display
updateProgressDisplay();
// Focus on the code editor
if (codeEditor) {
codeEditor.focus();
}
// Render the expected/solution preview
lessonEngine.renderExpectedPreview();
}
// ================= LIVE PREVIEW =================
let previewTimer = null;
function handleEditorChange(code) {
if (previewTimer) {
clearTimeout(previewTimer);
}
previewTimer = setTimeout(() => {
runCode();
}, 800);
}
// ================= NAVIGATION =================
function updateNavigationButtons() {
const engineState = lessonEngine.getCurrentState();
elements.prevBtn.disabled = !engineState.canGoPrev;
elements.nextBtn.disabled = !engineState.canGoNext;
elements.prevBtn.classList.toggle("btn-disabled", !engineState.canGoPrev);
elements.nextBtn.classList.toggle("btn-disabled", !engineState.canGoNext);
}
function nextLesson() {
const prevModuleId = lessonEngine.getCurrentState().module?.id;
const success = lessonEngine.nextLesson();
if (success) {
const newModuleId = lessonEngine.getCurrentState().module?.id;
if (newModuleId !== prevModuleId) {
updateModuleHighlight(newModuleId);
}
loadCurrentLesson();
}
}
function prevLesson() {
const prevModuleId = lessonEngine.getCurrentState().module?.id;
const success = lessonEngine.previousLesson();
if (success) {
const newModuleId = lessonEngine.getCurrentState().module?.id;
if (newModuleId !== prevModuleId) {
updateModuleHighlight(newModuleId);
}
loadCurrentLesson();
}
}
function updateModuleHighlight(moduleId) {
const moduleItems = elements.moduleList.querySelectorAll(".module-header");
moduleItems.forEach((item) => {
item.classList.remove("active");
if (item.dataset.moduleId === moduleId) {
item.classList.add("active");
}
});
}
// ================= CODE EXECUTION =================
function resetCode() {
// Reset editor to initial code for current lesson
lessonEngine.reset();
const engineState = lessonEngine.getCurrentState();
if (codeEditor && engineState.lesson) {
codeEditor.setValue(engineState.lesson.initialCode || "");
}
// Clear hints and success indicators
clearHint();
resetSuccessIndicators();
}
function runCode() {
const userCode = codeEditor ? codeEditor.getValue() : "";
// Rotate the Run button icon
const runButtonImg = document.querySelector("#run-btn img");
if (runButtonImg) {
const currentRotation = parseInt(runButtonImg.style.transform?.match(/\d+/)?.[0] || "0");
runButtonImg.style.transform = `rotate(${currentRotation + 180}deg)`;
}
// Apply the code to the preview via LessonEngine
lessonEngine.applyUserCode(userCode, true);
// Validate code using LessonEngine
const validationResult = lessonEngine.validateCode();
if (validationResult.isValid) {
// Show success hint
showSuccessHint(validationResult.message || "CRISPY! ٩(◕‿◕)۶ Your code works correctly.");
// Update Run button
elements.runBtn.innerHTML = '<img src="./gear.svg" alt="" />Re-run';
elements.runBtn.classList.add("success");
// Add completion badge
if (!document.querySelector(".completion-badge")) {
const badge = document.createElement("span");
badge.className = "completion-badge";
badge.textContent = "Completed";
elements.lessonTitle.appendChild(badge);
}
// Add success visual indicators
elements.codeEditor.classList.add("success-highlight");
elements.lessonTitle.classList.add("success-text");
elements.nextBtn.classList.add("success");
elements.taskInstruction.classList.add("success-instruction");
// Show match animation
elements.previewWrapper?.classList.add("matched");
setTimeout(() => {
elements.previewWrapper?.classList.remove("matched");
}, 2500);
updateNavigationButtons();
updateProgressDisplay();
} else {
// Reset success indicators
resetSuccessIndicators();
// Show hint with step progress
const step = validationResult.validCases + 1;
const total = validationResult.totalCases;
// Only show hints if enabled
if (!state.userSettings.disableFeedbackErrors) {
showHint(validationResult.message || "Keep trying!", step, total);
}
}
}
// ================= DIALOGS =================
function showHelp() {
elements.helpDialog.showModal();
}
function closeHelpDialog() {
elements.helpDialog.close();
}
function showResetConfirmation() {
elements.resetDialog.showModal();
}
function closeResetDialog() {
elements.resetDialog.close();
}
function handleResetConfirm() {
lessonEngine.clearProgress();
closeResetDialog();
closeSidebar();
// Reload first module
const modules = lessonEngine.modules;
if (modules.length > 0) {
selectModule(modules[0].id);
}
updateProgressDisplay();
}
// ================= INITIALIZATION =================
function initCodeEditor() {
const container = elements.editorContent;
if (!container) return;
// Remove the textarea - CodeMirror will replace it
const textarea = container.querySelector("textarea");
if (textarea) {
textarea.remove();
}
// Initialize CodeMirror
codeEditor = new CodeEditor(container, {
mode: currentMode,
placeholder: "Type your code here...",
onChange: handleEditorChange
});
codeEditor.init("");
}
function init() {
loadUserSettings();
// Initialize CodeMirror editor
initCodeEditor();
// Load modules after editor is ready
initializeModules().catch(console.error);
// Sidebar controls
elements.menuBtn.addEventListener("click", openSidebar);
elements.closeSidebar.addEventListener("click", closeSidebar);
elements.sidebarBackdrop.addEventListener("click", closeSidebar);
// Expected result toggle
elements.showExpectedBtn.addEventListener("click", toggleExpectedResult);
// Navigation
elements.prevBtn.addEventListener("click", prevLesson);
elements.nextBtn.addEventListener("click", nextLesson);
elements.runBtn.addEventListener("click", runCode);
// Editor tools
elements.undoBtn.addEventListener("click", () => {
if (codeEditor) codeEditor.undo();
});
elements.redoBtn.addEventListener("click", () => {
if (codeEditor) codeEditor.redo();
});
elements.resetCodeBtn.addEventListener("click", resetCode);
// Dialogs
elements.helpBtn.addEventListener("click", showHelp);
elements.helpDialogClose.addEventListener("click", closeHelpDialog);
elements.helpDialog.addEventListener("click", (e) => {
if (e.target === elements.helpDialog) closeHelpDialog();
});
elements.resetBtn.addEventListener("click", showResetConfirmation);
elements.resetDialogClose.addEventListener("click", closeResetDialog);
elements.resetDialog.addEventListener("click", (e) => {
if (e.target === elements.resetDialog) closeResetDialog();
});
elements.cancelReset.addEventListener("click", closeResetDialog);
elements.confirmReset.addEventListener("click", handleResetConfirm);
// Settings
elements.disableFeedbackToggle.addEventListener("change", (e) => {
state.userSettings.disableFeedbackErrors = !e.target.checked;
saveUserSettings();
});
// Click on editor content to focus CodeMirror
elements.editorContent?.addEventListener("click", () => {
if (codeEditor) codeEditor.focus();
});
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl+Enter to run code
if (e.ctrlKey && e.key === "Enter") {
runCode();
e.preventDefault();
}
// Escape to close sidebar (dialogs handle Escape natively)
if (e.key === "Escape") {
closeSidebar();
}
});
}
// Start the application
init();