From 5083032735b76d1ca28a3253911a44440942a587 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 14 Jan 2026 21:35:49 +0100 Subject: [PATCH] feat: add shareable lesson links with URL routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add share button with SVG link icon in lesson title row - Create share dialog with copy URL functionality - Implement URL hash-based routing for lesson navigation - Support browser back/forward navigation - Add i18n translations for share dialog in all languages - Position share button between title and completion badge - Add RTL support for title row layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/app.js | 164 +++++++++++++++++++++++++++++++++++++----- src/helpers/router.js | 57 +++++++++++++++ src/i18n.js | 42 +++++++++++ src/index.html | 26 ++++++- src/main.css | 128 +++++++++++++++++++++++---------- 5 files changed, 364 insertions(+), 53 deletions(-) create mode 100644 src/helpers/router.js 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 6ffdf95..a5d91ea 100644 --- a/src/index.html +++ b/src/index.html @@ -34,7 +34,15 @@
-

+
+

+ +
@@ -257,6 +265,22 @@
+ + + +
+

Share Lesson

+ +
+
+

Copy this URL to share the current lesson:

+ + +
+
diff --git a/src/main.css b/src/main.css index 5fa8dac..df7675a 100644 --- a/src/main.css +++ b/src/main.css @@ -257,7 +257,9 @@ kbd { font-weight: bold; cursor: pointer; color: var(--light-text); - transition: color 0.2s, border-color 0.2s; + transition: + color 0.2s, + border-color 0.2s; } .help-toggle:hover { @@ -356,15 +358,47 @@ kbd { opacity: 0.8; } +.lesson-title-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: var(--spacing-sm); + flex-wrap: wrap; +} + #lesson-title { font-size: 1.25rem; color: var(--primary-dark); - margin-bottom: var(--spacing-sm); + margin: 0; +} + +.share-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + color: var(--light-text); + border-radius: var(--border-radius-sm); + transition: color 0.2s, background 0.2s; +} + +.share-btn:hover { + color: var(--primary-color); + background: var(--primary-bg-light); +} + +.share-btn svg { + width: 16px; + height: 16px; } .completion-badge { display: inline-block; - margin-left: 0.5rem; padding: 0.15rem 0.5rem; background: linear-gradient(135deg, #9b59b6, #e040fb, #00bcd4, #7c4dff); color: white; @@ -584,14 +618,7 @@ kbd { position: absolute; inset: var(--spacing-md); border-radius: var(--border-radius-md); - background: conic-gradient( - from var(--border-angle), - #9b59b6, - #e040fb, - #00bcd4, - #7c4dff, - #9b59b6 - ); + background: conic-gradient(from var(--border-angle), #9b59b6, #e040fb, #00bcd4, #7c4dff, #9b59b6); filter: blur(30px); opacity: 0; animation: spin-glow 3s ease-out forwards; @@ -681,14 +708,7 @@ kbd { border: 6px solid transparent; background: linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box, - conic-gradient( - from 0deg, - #9b59b6, - #e040fb, - #00bcd4, - #7c4dff, - #9b59b6 - ) border-box; + conic-gradient(from 0deg, #9b59b6, #e040fb, #00bcd4, #7c4dff, #9b59b6) border-box; } .preview-wrapper.matched { @@ -696,19 +716,11 @@ kbd { border: 6px solid transparent; background: linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box, - conic-gradient( - from var(--border-angle), - #9b59b6, - #e040fb, - #00bcd4, - #7c4dff, - #9b59b6 - ) border-box; + conic-gradient(from var(--border-angle), #9b59b6, #e040fb, #00bcd4, #7c4dff, #9b59b6) border-box; animation: spin-border 3s ease-out forwards; overflow: visible; } - /* Animated CRISPY speech bubble with SVG tail */ .preview-wrapper.matched::after { content: var(--crispy-quote, "Crispyyyyyy!"); @@ -716,7 +728,10 @@ kbd { left: 55%; bottom: 0; transform: translateX(-50%) translateY(100%) scale(0.5); - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; font-size: 2rem; font-weight: 800; letter-spacing: 0.05em; @@ -1080,7 +1095,10 @@ button.lesson-list-item { cursor: pointer; font-family: var(--font-main); font-size: 0.9rem; - transition: background 0.2s, color 0.2s, border-color 0.2s; + transition: + background 0.2s, + color 0.2s, + border-color 0.2s; } .btn:hover { @@ -1319,7 +1337,9 @@ input:checked + .toggle-slider::before { align-items: center; justify-content: center; border-radius: 50%; - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; } .dialog-close:hover { @@ -1370,6 +1390,39 @@ input:checked + .toggle-slider::before { margin-top: var(--spacing-lg); } +/* Share Dialog */ +.share-url-container { + display: flex; + gap: var(--spacing-sm); + margin: var(--spacing-md) 0; +} + +.share-url-input { + flex: 1; + padding: var(--spacing-sm); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + font-family: var(--font-code); + font-size: 0.85rem; + background: var(--code-bg); + color: var(--text-color); +} + +.share-url-input:focus { + outline: none; + border-color: var(--primary-color); +} + +.copy-feedback { + color: var(--success-color); + font-size: 0.9rem; + margin-top: var(--spacing-sm); +} + +[dir="rtl"] .share-url-container { + flex-direction: row-reverse; +} + /* Project Cards in Help Dialog */ .project-cards { display: flex; @@ -1386,7 +1439,11 @@ input:checked + .toggle-slider::before { border: 1px solid var(--primary-bg-medium); text-decoration: none; color: var(--text-color); - transition: background 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s; + transition: + background 0.2s, + border-color 0.2s, + transform 0.2s, + box-shadow 0.2s; } .project-card:hover { @@ -1706,10 +1763,9 @@ input:checked + .toggle-slider::before { flex-direction: row-reverse; } -/* RTL: Completion badge spacing */ -[dir="rtl"] .completion-badge { - margin-left: 0; - margin-right: 0.5rem; +/* RTL: Lesson title row */ +[dir="rtl"] .lesson-title-row { + flex-direction: row-reverse; } /* RTL: Lists - bullets/numbers on right side */