diff --git a/src/app.js b/src/app.js index bf90653..68d1686 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,7 @@ 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"; // Simplified state - LessonEngine now manages lesson state and progress const state = { @@ -28,6 +29,7 @@ const elements = { modulePill: document.getElementById("module-pill"), moduleName: document.querySelector(".module-name"), lessonTitle: document.getElementById("lesson-title"), + lessonTitleRow: document.querySelector(".lesson-title-row"), lessonDescription: document.getElementById("lesson-description"), taskInstruction: document.getElementById("task-instruction"), codeInput: document.getElementById("code-input"), @@ -71,7 +73,15 @@ const elements = { resetCodeDialogClose: document.getElementById("reset-code-dialog-close"), cancelResetCode: document.getElementById("cancel-reset-code"), confirmResetCode: document.getElementById("confirm-reset-code"), - resetCodeDontShow: document.getElementById("reset-code-dont-show") + resetCodeDontShow: document.getElementById("reset-code-dont-show"), + + // Share dialog + shareBtn: document.getElementById("share-btn"), + shareDialog: document.getElementById("share-dialog"), + shareDialogClose: document.getElementById("share-dialog-close"), + shareUrlInput: document.getElementById("share-url-input"), + copyUrlBtn: document.getElementById("copy-url-btn"), + copyFeedback: document.getElementById("copy-feedback") }; // Initialize the lesson engine - now the single source of truth @@ -283,14 +293,22 @@ function initializeModules() { // 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; + // Check URL first for shareable links + const urlState = parseHash(); - if (lastModuleId && modules.find((m) => m.id === lastModuleId)) { - selectModule(lastModuleId); - } else if (modules.length > 0) { - selectModule(modules[0].id); + 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(); @@ -307,6 +325,10 @@ function selectModule(moduleId) { const success = lessonEngine.setModuleById(moduleId); if (!success) return; + // Update URL + const engineState = lessonEngine.getCurrentState(); + updateHash(moduleId, engineState.lessonIndex); + // Update module list UI to highlight the active module const moduleItems = elements.moduleList.querySelectorAll(".module-header"); moduleItems.forEach((item) => { @@ -332,6 +354,10 @@ function selectLesson(moduleId, lessonIndex) { } lessonEngine.setLessonByIndex(lessonIndex); + + // Update URL + updateHash(moduleId, lessonIndex); + loadCurrentLesson(); // Close sidebar after selection on mobile @@ -463,7 +489,7 @@ function loadCurrentLesson() { const badge = document.createElement("span"); badge.className = "completion-badge"; badge.textContent = t("completed"); - elements.lessonTitle.appendChild(badge); + elements.lessonTitleRow.appendChild(badge); } // Show gradient border for completed lessons @@ -556,9 +582,12 @@ 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); + const newState = lessonEngine.getCurrentState(); + // Update URL + updateHash(newState.module.id, newState.lessonIndex); + + if (newState.module.id !== prevModuleId) { + updateModuleHighlight(newState.module.id); } loadCurrentLesson(); } @@ -568,9 +597,12 @@ 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); + const newState = lessonEngine.getCurrentState(); + // Update URL + updateHash(newState.module.id, newState.lessonIndex); + + if (newState.module.id !== prevModuleId) { + updateModuleHighlight(newState.module.id); } loadCurrentLesson(); } @@ -636,7 +668,7 @@ function runCode() { const badge = document.createElement("span"); badge.className = "completion-badge"; badge.textContent = t("completed"); - elements.lessonTitle.appendChild(badge); + elements.lessonTitleRow.appendChild(badge); } // Add success visual indicators @@ -748,6 +780,93 @@ function handleResetCodeClick() { } } +// ================= SHARE DIALOG ================= + +function showShareDialog() { + const engineState = lessonEngine.getCurrentState(); + if (engineState.module && engineState.lesson !== null) { + const shareUrl = getShareableUrl(engineState.module.id, engineState.lessonIndex); + elements.shareUrlInput.value = shareUrl; + elements.copyFeedback.hidden = true; + } + elements.shareDialog.showModal(); +} + +function closeShareDialog() { + elements.shareDialog.close(); +} + +async function copyShareUrl() { + try { + await navigator.clipboard.writeText(elements.shareUrlInput.value); + elements.copyFeedback.hidden = false; + setTimeout(() => { + elements.copyFeedback.hidden = true; + }, 2000); + } catch (err) { + // Fallback for older browsers + elements.shareUrlInput.select(); + document.execCommand("copy"); + elements.copyFeedback.hidden = false; + setTimeout(() => { + elements.copyFeedback.hidden = true; + }, 2000); + } +} + +// ================= URL ROUTING ================= + +function initRouter() { + // Handle browser back/forward + window.addEventListener("popstate", handlePopState); +} + +function handlePopState() { + const parsed = parseHash(); + if (parsed) { + navigateToLesson(parsed.moduleId, parsed.lessonIndex, false); + } +} + +function navigateToLesson(moduleId, lessonIndex, shouldUpdateUrl = true) { + // Validate moduleId exists + const module = lessonEngine.modules.find((m) => m.id === moduleId); + if (!module) { + // Invalid module - fallback to first module + const fallbackModule = lessonEngine.modules[0]; + if (fallbackModule) { + replaceHash(fallbackModule.id, 0); + lessonEngine.setModuleById(fallbackModule.id); + lessonEngine.setLessonByIndex(0); + loadCurrentLesson(); + updateModuleHighlight(fallbackModule.id); + } + return; + } + + // Validate lessonIndex is in bounds + if (lessonIndex < 0 || lessonIndex >= module.lessons.length) { + // Invalid lesson - go to first lesson of module + replaceHash(moduleId, 0); + lessonEngine.setModuleById(moduleId); + lessonEngine.setLessonByIndex(0); + loadCurrentLesson(); + updateModuleHighlight(moduleId); + return; + } + + // Valid navigation + lessonEngine.setModuleById(moduleId); + lessonEngine.setLessonByIndex(lessonIndex); + + if (shouldUpdateUrl) { + updateHash(moduleId, lessonIndex); + } + + loadCurrentLesson(); + updateModuleHighlight(moduleId); +} + // ================= INITIALIZATION ================= function initCodeEditor() { @@ -788,6 +907,9 @@ function init() { // Load modules after editor is ready initializeModules(); + // Initialize URL router for shareable links + initRouter(); + // Sidebar controls elements.menuBtn.addEventListener("click", openSidebar); elements.closeSidebar.addEventListener("click", closeSidebar); @@ -797,7 +919,9 @@ function init() { elements.logoLink.addEventListener("click", (e) => { e.preventDefault(); lessonEngine.setModuleById("welcome"); + updateHash("welcome", 0); loadCurrentLesson(); + updateModuleHighlight("welcome"); }); // Language select @@ -820,6 +944,7 @@ function init() { if (codeEditor) codeEditor.redo(); }); elements.resetCodeBtn.addEventListener("click", handleResetCodeClick); + elements.shareBtn.addEventListener("click", showShareDialog); // Dialogs elements.helpBtn.addEventListener("click", showHelp); @@ -841,6 +966,13 @@ function init() { elements.cancelResetCode.addEventListener("click", closeResetCodeDialog); elements.confirmResetCode.addEventListener("click", handleResetCodeConfirm); + // Share dialog + elements.shareDialogClose.addEventListener("click", closeShareDialog); + elements.shareDialog.addEventListener("click", (e) => { + if (e.target === elements.shareDialog) closeShareDialog(); + }); + elements.copyUrlBtn.addEventListener("click", copyShareUrl); + // Settings elements.disableFeedbackToggle.addEventListener("change", (e) => { state.userSettings.disableFeedbackErrors = !e.target.checked; diff --git a/src/helpers/router.js b/src/helpers/router.js new file mode 100644 index 0000000..9806016 --- /dev/null +++ b/src/helpers/router.js @@ -0,0 +1,57 @@ +/** + * URL Router for Code Crispies + * Handles hash-based routing for shareable lesson links + * Format: #module-id/lesson-index (e.g., #flexbox/2) + */ + +/** + * Parse current URL hash into module and lesson info + * @returns {{ moduleId: string, lessonIndex: number } | null} + */ +export function parseHash() { + const hash = window.location.hash.slice(1); // Remove '#' + if (!hash) return null; + + const parts = hash.split("/"); + if (parts.length !== 2) return null; + + const moduleId = parts[0]; + const lessonIndex = parseInt(parts[1], 10); + + if (!moduleId || isNaN(lessonIndex) || lessonIndex < 0) return null; + + return { moduleId, lessonIndex }; +} + +/** + * Update URL hash with history entry (for navigation) + * @param {string} moduleId + * @param {number} lessonIndex + */ +export function updateHash(moduleId, lessonIndex) { + const newHash = `#${moduleId}/${lessonIndex}`; + if (window.location.hash !== newHash) { + history.pushState(null, "", newHash); + } +} + +/** + * Replace URL hash without history entry (for invalid URL fallbacks) + * @param {string} moduleId + * @param {number} lessonIndex + */ +export function replaceHash(moduleId, lessonIndex) { + const newHash = `#${moduleId}/${lessonIndex}`; + history.replaceState(null, "", newHash); +} + +/** + * Build full shareable URL for current lesson + * @param {string} moduleId + * @param {number} lessonIndex + * @returns {string} + */ +export function getShareableUrl(moduleId, lessonIndex) { + const base = window.location.origin + window.location.pathname; + return `${base}#${moduleId}/${lessonIndex}`; +} diff --git a/src/i18n.js b/src/i18n.js index b2dcea4..fda41d1 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -99,6 +99,13 @@ const translations = { dontShowAgain: "Don't show this again", reset: "Reset", + // Share dialog + shareDialogTitle: "Share Lesson", + shareDialogText: "Copy this URL to share the current lesson:", + shareTitle: "Share lesson", + copyUrl: "Copy", + urlCopied: "URL copied to clipboard!", + // Dynamic content loadingFallbackText: "Could not load lesson. Please select one from the menu or check the help.", completed: "Completed", @@ -207,6 +214,13 @@ const translations = { dontShowAgain: "Nicht mehr anzeigen", reset: "Zurücksetzen", + // Share dialog + shareDialogTitle: "Lektion teilen", + shareDialogText: "Kopiere diese URL, um die aktuelle Lektion zu teilen:", + shareTitle: "Lektion teilen", + copyUrl: "Kopieren", + urlCopied: "URL in die Zwischenablage kopiert!", + // Dynamic content loadingFallbackText: "Lektion konnte nicht geladen werden. Bitte wähle eine aus dem Menü oder prüfe die Hilfe.", completed: "Erledigt", @@ -315,6 +329,13 @@ const translations = { dontShowAgain: "Nie pokazuj ponownie", reset: "Resetuj", + // Share dialog + shareDialogTitle: "Udostępnij lekcję", + shareDialogText: "Skopiuj ten URL, aby udostępnić bieżącą lekcję:", + shareTitle: "Udostępnij lekcję", + copyUrl: "Kopiuj", + urlCopied: "URL skopiowany do schowka!", + // Dynamic content loadingFallbackText: "Nie można załadować lekcji. Wybierz jedną z menu lub sprawdź pomoc.", completed: "Ukończono", @@ -424,6 +445,13 @@ const translations = { dontShowAgain: "No mostrar de nuevo", reset: "Reiniciar", + // Share dialog + shareDialogTitle: "Compartir lección", + shareDialogText: "Copia esta URL para compartir la lección actual:", + shareTitle: "Compartir lección", + copyUrl: "Copiar", + urlCopied: "¡URL copiada al portapapeles!", + // Dynamic content loadingFallbackText: "No se pudo cargar la lección. Selecciona una del menú o consulta la ayuda.", completed: "Completado", @@ -531,6 +559,13 @@ const translations = { dontShowAgain: "لا تظهر هذا مرة أخرى", reset: "إعادة تعيين", + // Share dialog + shareDialogTitle: "مشاركة الدرس", + shareDialogText: "انسخ هذا الرابط لمشاركة الدرس الحالي:", + shareTitle: "مشاركة الدرس", + copyUrl: "نسخ", + urlCopied: "تم نسخ الرابط إلى الحافظة!", + // Dynamic content loadingFallbackText: "تعذر تحميل الدرس. اختر واحدًا من القائمة أو تحقق من المساعدة.", completed: "مكتمل", @@ -639,6 +674,13 @@ const translations = { dontShowAgain: "Більше не показувати", reset: "Скинути", + // Share dialog + shareDialogTitle: "Поділитися уроком", + shareDialogText: "Скопіюйте цю URL-адресу, щоб поділитися поточним уроком:", + shareTitle: "Поділитися уроком", + copyUrl: "Копіювати", + urlCopied: "URL-адресу скопійовано до буфера обміну!", + // Dynamic content loadingFallbackText: "Не вдалося завантажити урок. Виберіть один з меню або перевірте допомогу.", completed: "Завершено", diff --git a/src/index.html b/src/index.html index 0fd4434..ed097f5 100644 --- a/src/index.html +++ b/src/index.html @@ -34,7 +34,15 @@