From 1d59d18870784222a447c0727e54e6d23f83310a Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 11:06:42 +0100 Subject: [PATCH 01/25] feat: add newsletter signup with email field and Umami tracking - Add email input field to newsletter signup form - Add disclaimer about max frequency and unsubscribe option - Add newsletter translations for all 6 languages (en, de, pl, es, ar, uk) - Update hero highlight to "Crispy Code" - Update CTA button to "Let's get crispy!" - Add Umami tracking for newsletter submissions - Style newsletter form without white background --- src/app.js | 13 +++++++++ src/i18n.js | 66 ++++++++++++++++++++++++++++++++++++--------- src/index.html | 29 +++++++++++++++----- src/main.css | 72 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 19 deletions(-) diff --git a/src/app.js b/src/app.js index 0145ea9..ade1085 100644 --- a/src/app.js +++ b/src/app.js @@ -2560,6 +2560,19 @@ function init() { track("support_click", { location: "landing" }); } }); + + // Newsletter form submission + const newsletterForm = document.getElementById("newsletter-form"); + const newsletterThanks = document.getElementById("newsletter-thanks"); + newsletterForm?.addEventListener("submit", (e) => { + e.preventDefault(); + const email = document.getElementById("newsletter-email")?.value; + if (email) { + track("newsletter_signup", { email }); + newsletterForm.classList.add("hidden"); + newsletterThanks?.classList.remove("hidden"); + } + }); } // Start the application diff --git a/src/i18n.js b/src/i18n.js index ff443dc..7945ab2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -118,7 +118,7 @@ const translations = { // Landing page landingHeroTitle: "Learn Web Development", - landingHeroHighlight: "By Writing Real Code", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "Master HTML, CSS, and Tailwind through hands-on exercises with instant feedback. Free and open source.", landingCtaStart: "Start Learning NOW", landingWhyTitle: "Why CODE CRISPIES Works", @@ -137,7 +137,7 @@ const translations = { comingSoon: "Coming Soon", landingCtaTitle: "Start Learning Today", landingCtaSub: "Free and open source. No account required. Progress saved locally.", - landingCtaButton: "Begin Your Journey", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "Coming Soon", @@ -150,6 +150,13 @@ const translations = { comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "React, Vue, and Svelte basics. Build real components step by step.", + // Newsletter + newsletterText: "Want to know when new features launch?", + newsletterPlaceholder: "your@email.com", + newsletterButton: "Notify Me", + newsletterThanks: "Thanks! We'll keep you posted.", + newsletterDisclaimer: "Max once a week. Unsubscribe anytime via mail@codecrispi.es", + // Device Notice deviceNotice: "Best on desktop or tablet (landscape). Mobile works, but larger screens make coding easier.", @@ -283,7 +290,7 @@ const translations = { // Landing page landingHeroTitle: "Web Programmierung", - landingHeroHighlight: "Selbstständig lernen", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "Meistere HTML, CSS und Tailwind durch praktische Übungen mit sofortigem Feedback. Kostenlos und Open Source.", landingCtaStart: "Jetzt starten", landingWhyTitle: "Warum CODE CRISPIES funktioniert", @@ -304,7 +311,7 @@ const translations = { comingSoon: "Bald verfügbar", landingCtaTitle: "Heute noch anfangen", landingCtaSub: "Kostenlos und Open Source. Kein Konto erforderlich. Fortschritt wird lokal gespeichert.", - landingCtaButton: "Jetzt erste Schritte machen", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "Demnächst", @@ -317,6 +324,13 @@ const translations = { comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "React, Vue und Svelte Grundlagen. Baue echte Komponenten Schritt für Schritt.", + // Newsletter + newsletterText: "Möchtest du erfahren, wenn neue Funktionen erscheinen?", + newsletterPlaceholder: "deine@email.de", + newsletterButton: "Benachrichtigen", + newsletterThanks: "Danke! Wir halten dich auf dem Laufenden.", + newsletterDisclaimer: "Max. einmal pro Woche. Jederzeit abmelden über mail@codecrispi.es", + // Device Notice deviceNotice: "Am besten auf Desktop oder Tablet (Querformat). Mobil funktioniert, aber größere Bildschirme machen das Coden einfacher.", @@ -450,7 +464,7 @@ const translations = { // Landing page landingHeroTitle: "Naucz się tworzenia stron", - landingHeroHighlight: "Pisząc prawdziwy kod", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "Opanuj HTML, CSS i Tailwind poprzez praktyczne ćwiczenia z natychmiastową informacją zwrotną. Darmowe i open source.", landingCtaStart: "Zacznij TERAZ", landingWhyTitle: "Dlaczego CODE CRISPIES działa", @@ -471,7 +485,7 @@ const translations = { comingSoon: "Wkrótce", landingCtaTitle: "Zacznij naukę już dziś", landingCtaSub: "Darmowe i open source. Bez konta. Postęp zapisywany lokalnie.", - landingCtaButton: "Rozpocznij swoją podróż", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "Wkrótce", @@ -484,6 +498,13 @@ const translations = { comingSoonFrameworksTitle: "Frameworki", comingSoonFrameworksText: "Podstawy React, Vue i Svelte. Buduj prawdziwe komponenty krok po kroku.", + // Newsletter + newsletterText: "Chcesz wiedzieć, kiedy pojawią się nowe funkcje?", + newsletterPlaceholder: "twoj@email.pl", + newsletterButton: "Powiadom mnie", + newsletterThanks: "Dzięki! Będziemy informować.", + newsletterDisclaimer: "Maks. raz w tygodniu. Wypisz się w dowolnym momencie przez mail@codecrispi.es", + // Device Notice deviceNotice: "Najlepiej na komputerze lub tablecie (poziomo). Na telefonie też działa, ale większy ekran ułatwia kodowanie.", @@ -618,7 +639,7 @@ const translations = { // Landing page landingHeroTitle: "Aprende desarrollo web", - landingHeroHighlight: "Escribiendo código real", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "Domina HTML, CSS y Tailwind a través de ejercicios prácticos con retroalimentación instantánea. Gratis y de código abierto.", landingCtaStart: "Empieza AHORA", @@ -640,7 +661,7 @@ const translations = { comingSoon: "Próximamente", landingCtaTitle: "Empieza a aprender hoy", landingCtaSub: "Gratis y de código abierto. Sin cuenta requerida. Progreso guardado localmente.", - landingCtaButton: "Comienza tu viaje", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "Próximamente", @@ -653,6 +674,13 @@ const translations = { comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "Fundamentos de React, Vue y Svelte. Construye componentes reales paso a paso.", + // Newsletter + newsletterText: "¿Quieres saber cuando se lancen nuevas funciones?", + newsletterPlaceholder: "tu@email.com", + newsletterButton: "Notificarme", + newsletterThanks: "¡Gracias! Te mantendremos informado.", + newsletterDisclaimer: "Máximo una vez por semana. Cancela cuando quieras vía mail@codecrispi.es", + // Device Notice deviceNotice: "Mejor en escritorio o tablet (horizontal). Funciona en móvil, pero pantallas más grandes facilitan la programación.", @@ -785,7 +813,7 @@ const translations = { // Landing page landingHeroTitle: "تعلم تطوير الويب", - landingHeroHighlight: "بكتابة كود حقيقي", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "أتقن HTML و CSS و Tailwind من خلال تمارين عملية مع ملاحظات فورية. مجاني ومفتوح المصدر.", landingCtaStart: "ابدأ الآن", landingWhyTitle: "لماذا CODE CRISPIES فعال", @@ -804,7 +832,7 @@ const translations = { comingSoon: "قريباً", landingCtaTitle: "ابدأ التعلم اليوم", landingCtaSub: "مجاني ومفتوح المصدر. لا حاجة لحساب. يُحفظ التقدم محليًا.", - landingCtaButton: "ابدأ رحلتك", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "قريباً", @@ -817,6 +845,13 @@ const translations = { comingSoonFrameworksTitle: "أطر العمل", comingSoonFrameworksText: "أساسيات React وVue وSvelte. ابنِ مكونات حقيقية خطوة بخطوة.", + // Newsletter + newsletterText: "هل تريد معرفة متى تُطلق ميزات جديدة؟", + newsletterPlaceholder: "بريدك@email.com", + newsletterButton: "أبلغني", + newsletterThanks: "شكراً! سنبقيك على اطلاع.", + newsletterDisclaimer: "مرة واحدة أسبوعياً كحد أقصى. إلغاء الاشتراك في أي وقت عبر mail@codecrispi.es", + // Device Notice deviceNotice: "أفضل على الكمبيوتر أو الجهاز اللوحي (أفقي). يعمل على الجوال، لكن الشاشات الأكبر تسهّل البرمجة.", @@ -950,7 +985,7 @@ const translations = { // Landing page landingHeroTitle: "Вивчай веб-розробку", - landingHeroHighlight: "Пишучи справжній код", + landingHeroHighlight: "Crispy Code", landingHeroSubtitle: "Опануй HTML, CSS та Tailwind через практичні вправи з миттєвим зворотним зв'язком. Безкоштовно та з відкритим кодом.", landingCtaStart: "Почни ЗАРАЗ", landingWhyTitle: "Чому CODE CRISPIES працює", @@ -970,7 +1005,7 @@ const translations = { comingSoon: "Незабаром", landingCtaTitle: "Почни вчитися сьогодні", landingCtaSub: "Безкоштовно та з відкритим кодом. Без реєстрації. Прогрес зберігається локально.", - landingCtaButton: "Розпочни свою подорож", + landingCtaButton: "Let's get crispy!", // Coming Soon landingComingSoonTitle: "Незабаром", @@ -983,6 +1018,13 @@ const translations = { comingSoonFrameworksTitle: "Фреймворки", comingSoonFrameworksText: "Основи React, Vue та Svelte. Створюй справжні компоненти крок за кроком.", + // Newsletter + newsletterText: "Хочете дізнатися, коли з'являться нові функції?", + newsletterPlaceholder: "ваш@email.com", + newsletterButton: "Повідомити мене", + newsletterThanks: "Дякуємо! Ми будемо тримати вас в курсі.", + newsletterDisclaimer: "Максимум раз на тиждень. Відписатися можна будь-коли через mail@codecrispi.es", + // Device Notice deviceNotice: "Найкраще на комп'ютері або планшеті (горизонтально). На телефоні теж працює, але більший екран полегшує програмування.", diff --git a/src/index.html b/src/index.html index a1c18f1..d644a59 100644 --- a/src/index.html +++ b/src/index.html @@ -173,37 +173,54 @@

Coming Soon

- 🔄 + + +

Cloud Sync

Sync your progress across all devices. Start on desktop, continue on tablet.

- 🏆 + + +

Achievements

Earn badges as you master new skills. Track your learning milestones.

- + + +

JavaScript

Interactive JavaScript lessons with live code execution and DOM manipulation.

- 🧩 + + +

Frameworks

React, Vue, and Svelte basics. Build real components step by step.

+
+

Want to know when new features launch?

+ + + +
-

+

Best on desktop or tablet (landscape). Mobile works, but larger screens make coding easier.

Start Learning Today

- Begin Your Journey + Let's get crispy!

Free and open source. No account required. Progress saved locally.

diff --git a/src/main.css b/src/main.css index 7423163..a4348ca 100644 --- a/src/main.css +++ b/src/main.css @@ -1924,11 +1924,16 @@ input:checked + .toggle-slider::before { } .coming-soon-icon { - font-size: 2rem; display: block; margin-bottom: 0.75rem; } +.coming-soon-icon svg { + width: 2rem; + height: 2rem; + stroke: var(--section-color); +} + .coming-soon-card h3 { font-size: 1rem; margin-bottom: 0.5rem; @@ -1954,6 +1959,71 @@ input:checked + .toggle-slider::before { } } +/* Newsletter Signup */ +.newsletter-signup { + margin-top: var(--spacing-lg); + padding: 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.newsletter-signup p { + margin: 0; + color: var(--light-text); +} + +.newsletter-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; +} + +.newsletter-form input[type="email"] { + padding: 0.5rem 1rem; + border: 2px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--panel-bg); + color: var(--text); + font-size: 1rem; + min-width: 200px; +} + +.newsletter-form input[type="email"]:focus { + outline: none; + border-color: var(--section-color); +} + +.newsletter-signup .btn-outline { + border: 2px solid var(--section-color); + color: var(--section-color); + background: transparent; + padding: 0.5rem 1.5rem; + font-weight: 500; + transition: all 0.2s; +} + +.newsletter-signup .btn-outline:hover { + background: var(--section-color); + color: white; +} + +.newsletter-disclaimer { + font-size: 0.8rem; + opacity: 0.7; +} + +.newsletter-thanks { + color: var(--success); + font-weight: 500; +} + +.newsletter-thanks.hidden { + display: none; +} + /* Device Notice */ .device-notice { margin-top: var(--spacing-lg); From ca1248abf1dd629e4e7ec3059e4d999dde6c9556 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 11:16:26 +0100 Subject: [PATCH 02/25] fix: add console.log to debug newsletter tracking --- src/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index ade1085..b56c683 100644 --- a/src/app.js +++ b/src/app.js @@ -2568,7 +2568,8 @@ function init() { e.preventDefault(); const email = document.getElementById("newsletter-email")?.value; if (email) { - track("newsletter_signup", { email }); + console.log("Newsletter signup:", email); + track("newsletter_signup", { email: email }); newsletterForm.classList.add("hidden"); newsletterThanks?.classList.remove("hidden"); } From dbaef72c79d0b7f468ae95b9dd73bc663de1f3e9 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 11:16:49 +0100 Subject: [PATCH 03/25] fix: add tracking debug logs for success and blocked states --- src/app.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index b56c683..544f53f 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,9 @@ import { css } from "@codemirror/lang-css"; function track(eventName, eventData = {}) { if (typeof umami !== "undefined" && umami.track) { umami.track(eventName, eventData); + console.log("Track:", eventName, eventData); + } else { + console.log("Track blocked (umami unavailable):", eventName, eventData); } } @@ -2568,7 +2571,6 @@ function init() { e.preventDefault(); const email = document.getElementById("newsletter-email")?.value; if (email) { - console.log("Newsletter signup:", email); track("newsletter_signup", { email: email }); newsletterForm.classList.add("hidden"); newsletterThanks?.classList.remove("hidden"); From 988d2fdcfeb39744fe8982f4ada7478478f817f7 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 11:19:14 +0100 Subject: [PATCH 04/25] fix: change tracking logs to console.debug --- src/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.js b/src/app.js index 544f53f..e9d3584 100644 --- a/src/app.js +++ b/src/app.js @@ -17,9 +17,9 @@ import { css } from "@codemirror/lang-css"; function track(eventName, eventData = {}) { if (typeof umami !== "undefined" && umami.track) { umami.track(eventName, eventData); - console.log("Track:", eventName, eventData); + console.debug("Track:", eventName, eventData); } else { - console.log("Track blocked (umami unavailable):", eventName, eventData); + console.debug("Track blocked (umami unavailable):", eventName, eventData); } } From b0b39e2f0289684f9cfeff270c1d4f8aa6675b4f Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 12:37:22 +0100 Subject: [PATCH 05/25] feat: add authentication, cloud sync, and GDPR compliance Authentication & Cloud Sync: - Add Supabase integration for auth (email/password, Google, GitHub OAuth) - Add cloud progress sync for logged-in users - Add account deletion feature with confirmation dialog - Auth is optional - anonymous users can still use localStorage UI Improvements: - Add dark-themed account section in sidebar - Show user email in header when logged in - Add signup success feedback message - Update landing page: remove cloud sync from Coming Soon, add Code Challenges - Update benefit text to mention optional cloud sync GDPR Compliance: - Add Privacy Policy dialog with full GDPR-compliant content - Add Imprint dialog with legal contact information - Add footer links for Privacy and Imprint - All legal content translated to 6 languages (en, de, pl, es, ar, uk) Files added: - src/supabase.js - Supabase client with auth and progress sync helpers - src/auth.js - Authentication logic and form handlers - supabase-setup.sql - Database schema and RLS policies --- .gitignore | 2 + package-lock.json | 129 +++++++++++- package.json | 1 + src/app.js | 41 +++- src/auth.js | 419 +++++++++++++++++++++++++++++++++++++++ src/i18n.js | 304 ++++++++++++++++++++++++++-- src/impl/LessonEngine.js | 34 +++- src/index.html | 190 +++++++++++++++++- src/main.css | 274 +++++++++++++++++++++++++ src/supabase.js | 98 +++++++++ supabase-setup.sql | 53 +++++ vite.config.js | 1 + 12 files changed, 1520 insertions(+), 26 deletions(-) create mode 100644 src/auth.js create mode 100644 src/supabase.js create mode 100644 supabase-setup.sql diff --git a/.gitignore b/.gitignore index a6848d2..b696bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules dist coverage +.env +.env.local # Claude Code local settings (user-specific) .claude/settings.local.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e1a485c..3492660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "code-crispies", "version": "1.0.0", - "license": "Copyright 2025 (c) Michael Czechowski", + "license": "Copyright 2026 (c) Michael Czechowski", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -17,6 +17,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.4", "@emmetio/codemirror6-plugin": "^0.4.0", + "@supabase/supabase-js": "^2.90.1", "codemirror": "^6.0.2", "whatwg-fetch": "^3.6.20" }, @@ -1354,6 +1355,86 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", + "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", + "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", + "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", + "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", + "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.90.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", + "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.90.1", + "@supabase/functions-js": "2.90.1", + "@supabase/postgrest-js": "2.90.1", + "@supabase/realtime-js": "2.90.1", + "@supabase/storage-js": "2.90.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1433,6 +1514,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -2092,6 +2197,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2991,6 +3105,18 @@ "node": ">=18" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -3374,7 +3500,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index bcd6053..3abcaf9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.4", "@emmetio/codemirror6-plugin": "^0.4.0", + "@supabase/supabase-js": "^2.90.1", "codemirror": "^6.0.2", "whatwg-fetch": "^3.6.20" } diff --git a/src/app.js b/src/app.js index e9d3584..b6438f3 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,7 @@ import { initI18n, t, getLanguage, setLanguage, applyTranslations } from "./i18n import { parseHash, updateHash, replaceHash, getShareableUrl, RouteType, navigateTo } from "./helpers/router.js"; import { sections, getSection, getModuleSection, getModulesBySection } from "./config/sections.js"; import { getRandomTemplate } from "./config/playground-templates.js"; +import { initAuth } from "./auth.js"; // CodeMirror imports for syntax highlighting import { EditorState } from "@codemirror/state"; @@ -2423,6 +2424,9 @@ function init() { // Initialize URL router for shareable links initRouter(); + // Initialize authentication + initAuth(lessonEngine); + // Sidebar controls elements.menuBtn.addEventListener("click", openSidebar); elements.closeSidebar.addEventListener("click", closeSidebar); @@ -2485,6 +2489,31 @@ function init() { }); elements.copyUrlBtn.addEventListener("click", copyShareUrl); + // Legal dialogs (Privacy & Imprint) + const privacyDialog = document.getElementById("privacy-dialog"); + const imprintDialog = document.getElementById("imprint-dialog"); + + document.querySelectorAll(".privacy-link").forEach((btn) => { + btn.addEventListener("click", () => privacyDialog?.showModal()); + }); + document.querySelectorAll(".imprint-link").forEach((btn) => { + btn.addEventListener("click", () => imprintDialog?.showModal()); + }); + + document.querySelector(".privacy-dialog-close")?.addEventListener("click", () => { + privacyDialog?.close(); + }); + document.querySelector(".imprint-dialog-close")?.addEventListener("click", () => { + imprintDialog?.close(); + }); + + privacyDialog?.addEventListener("click", (e) => { + if (e.target === privacyDialog) privacyDialog.close(); + }); + imprintDialog?.addEventListener("click", (e) => { + if (e.target === imprintDialog) imprintDialog.close(); + }); + // Settings elements.disableFeedbackToggle.addEventListener("change", (e) => { state.userSettings.disableFeedbackErrors = !e.target.checked; @@ -2567,10 +2596,18 @@ function init() { // Newsletter form submission const newsletterForm = document.getElementById("newsletter-form"); const newsletterThanks = document.getElementById("newsletter-thanks"); - newsletterForm?.addEventListener("submit", (e) => { + newsletterForm?.addEventListener("submit", async (e) => { e.preventDefault(); - const email = document.getElementById("newsletter-email")?.value; + const emailInput = document.getElementById("newsletter-email"); + const email = emailInput?.value; if (email) { + // Import newsletter helper dynamically to avoid loading Supabase if not needed + try { + const { newsletter } = await import("./supabase.js"); + await newsletter.subscribe(email); + } catch (err) { + console.error("Newsletter subscription error:", err); + } track("newsletter_signup", { email: email }); newsletterForm.classList.add("hidden"); newsletterThanks?.classList.remove("hidden"); diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..3d89887 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,419 @@ +import { t, applyTranslations } from "./i18n.js"; + +let currentUser = null; +let lessonEngineRef = null; +let authModule = null; +let progressModule = null; +let supabaseAvailable = false; + +/** + * Initialize the auth system + * @param {Object} engine - The LessonEngine instance + */ +export async function initAuth(engine) { + lessonEngineRef = engine; + + // Try to load Supabase - if not configured, auth is disabled + try { + const supabaseModule = await import("./supabase.js"); + + // Check if Supabase is configured via environment variables + if (!supabaseModule.isConfigured) { + console.log("Supabase not configured - auth disabled"); + hideAuthUI(); + return; + } + + authModule = supabaseModule.auth; + progressModule = supabaseModule.progressDB; + supabaseAvailable = true; + } catch (e) { + console.log("Supabase not available - auth disabled:", e.message); + hideAuthUI(); + return; + } + + // Check initial session + try { + const { data } = await authModule.getUser(); + if (data?.user) handleLogin(data.user); + } catch (e) { + console.log("Auth check failed:", e.message); + } + + // Listen for auth changes + authModule.onAuthStateChange((event, session) => { + if (event === "SIGNED_IN" && session?.user) { + handleLogin(session.user); + } else if (event === "SIGNED_OUT") { + handleLogout(); + } + }); + + // Attach form handlers + setupAuthForms(); +} + +function hideAuthUI() { + document.getElementById("auth-trigger-header")?.classList.add("hidden"); + document.querySelector(".sidebar-auth-box")?.classList.add("hidden"); +} + +async function handleLogin(user) { + currentUser = user; + updateAuthUI(user); + + if (!progressModule) return; + + // Load cloud progress + const { data } = await progressModule.load(user.id); + + if (data) { + // Merge with localStorage (cloud wins for conflicts) + mergeProgress(data); + } else { + // First login: upload localStorage to cloud + await syncToCloud(); + } +} + +function handleLogout() { + currentUser = null; + updateAuthUI(null); + // Keep localStorage progress, just disconnect from cloud +} + +function updateAuthUI(user) { + // Header elements + const authTriggerHeader = document.getElementById("auth-trigger-header"); + const userEmailHeader = document.getElementById("user-email-header"); + + // Sidebar elements + const authTriggerSidebar = document.getElementById("auth-trigger-sidebar"); + const userMenuSidebar = document.getElementById("user-menu-sidebar"); + const userEmailSidebar = document.getElementById("user-email-sidebar"); + const sidebarHint = document.querySelector(".sidebar-auth-hint"); + + if (user) { + authTriggerHeader?.classList.add("hidden"); + userEmailHeader?.classList.remove("hidden"); + authTriggerSidebar?.classList.add("hidden"); + userMenuSidebar?.classList.remove("hidden"); + sidebarHint?.classList.add("hidden"); + if (userEmailHeader) userEmailHeader.textContent = user.email; + if (userEmailSidebar) userEmailSidebar.textContent = user.email; + } else { + authTriggerHeader?.classList.remove("hidden"); + userEmailHeader?.classList.add("hidden"); + authTriggerSidebar?.classList.remove("hidden"); + userMenuSidebar?.classList.add("hidden"); + sidebarHint?.classList.remove("hidden"); + } +} + +export async function syncToCloud() { + if (!currentUser || !progressModule) return; + + const progress = JSON.parse( + localStorage.getItem("codeCrispies.progress") || "{}" + ); + const userCodeEntries = JSON.parse( + localStorage.getItem("codeCrispies.userCode") || "[]" + ); + const userCode = Object.fromEntries(userCodeEntries); + const settings = JSON.parse( + localStorage.getItem("codeCrispies.settings") || "{}" + ); + const language = localStorage.getItem("codeCrispies.language") || "en"; + + await progressModule.save(currentUser.id, progress, userCode, settings, language); +} + +function mergeProgress(cloudData) { + // Update localStorage with cloud data + localStorage.setItem( + "codeCrispies.progress", + JSON.stringify(cloudData.progress) + ); + localStorage.setItem( + "codeCrispies.userCode", + JSON.stringify(Object.entries(cloudData.user_code)) + ); + localStorage.setItem( + "codeCrispies.settings", + JSON.stringify(cloudData.settings) + ); + localStorage.setItem("codeCrispies.language", cloudData.language); + + // Reload engine state + if (lessonEngineRef) { + lessonEngineRef.loadUserProgress(); + lessonEngineRef.loadUserCodeFromStorage(); + } +} + +export function isLoggedIn() { + return supabaseAvailable && currentUser !== null; +} + +export function getCurrentUser() { + return currentUser; +} + +// Debounce utility +function debounce(fn, delay) { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} + +// Export debounced sync for use by LessonEngine +export const debouncedSyncToCloud = debounce(() => syncToCloud(), 2000); + +function setupAuthForms() { + const authDialog = document.getElementById("auth-dialog"); + const loginForm = document.getElementById("login-form"); + const signupForm = document.getElementById("signup-form"); + const resetForm = document.getElementById("reset-form"); + + // Form submissions + loginForm?.addEventListener("submit", handleLoginSubmit); + signupForm?.addEventListener("submit", handleSignupSubmit); + resetForm?.addEventListener("submit", handleResetSubmit); + + // Form switchers + document + .getElementById("show-signup") + ?.addEventListener("click", () => switchForm("signup")); + document + .getElementById("show-login") + ?.addEventListener("click", () => switchForm("login")); + document + .getElementById("show-reset") + ?.addEventListener("click", () => switchForm("reset")); + + // Dialog triggers (both header and sidebar) + document + .getElementById("auth-trigger-header") + ?.addEventListener("click", () => { + authDialog?.showModal(); + }); + document + .getElementById("auth-trigger-sidebar") + ?.addEventListener("click", () => { + authDialog?.showModal(); + }); + + // Logout button (sidebar only) + document + .getElementById("logout-btn-sidebar") + ?.addEventListener("click", async () => { + await authModule?.signOut(); + }); + + // Delete account button and dialog + const deleteDialog = document.getElementById("delete-account-dialog"); + + document + .getElementById("delete-account-btn") + ?.addEventListener("click", () => { + deleteDialog?.showModal(); + }); + + document + .getElementById("cancel-delete") + ?.addEventListener("click", () => { + deleteDialog?.close(); + }); + + document + .getElementById("delete-dialog-close") + ?.addEventListener("click", () => { + deleteDialog?.close(); + }); + + deleteDialog?.addEventListener("click", (e) => { + if (e.target === deleteDialog) deleteDialog.close(); + }); + + document + .getElementById("confirm-delete") + ?.addEventListener("click", async () => { + const errorEl = document.getElementById("delete-account-error"); + const confirmBtn = document.getElementById("confirm-delete"); + + confirmBtn.disabled = true; + + const { error } = await authModule.deleteAccount(); + + if (error) { + errorEl.textContent = error.message; + errorEl.classList.remove("hidden"); + confirmBtn.disabled = false; + } else { + errorEl.classList.add("hidden"); + deleteDialog.close(); + // Sign out and clear local state + await authModule.signOut(); + } + }); + + // OAuth buttons + document.getElementById("google-login")?.addEventListener("click", () => { + authModule?.signInWithGoogle(); + }); + + document.getElementById("github-login")?.addEventListener("click", () => { + authModule?.signInWithGitHub(); + }); + + // Close dialog on backdrop click + authDialog?.addEventListener("click", (e) => { + if (e.target === authDialog) authDialog.close(); + }); + + // Close button + authDialog?.querySelector(".close-dialog")?.addEventListener("click", () => { + authDialog.close(); + }); +} + +async function handleLoginSubmit(e) { + e.preventDefault(); + const email = document.getElementById("login-email").value; + const password = document.getElementById("login-password").value; + const errorEl = document.getElementById("login-error"); + const submitBtn = e.target.querySelector('button[type="submit"]'); + + // Disable button while processing + submitBtn.disabled = true; + + const { error } = await authModule.signIn(email, password); + + submitBtn.disabled = false; + + if (error) { + errorEl.textContent = error.message; + errorEl.classList.remove("hidden"); + } else { + errorEl.classList.add("hidden"); + document.getElementById("auth-dialog").close(); + } +} + +async function handleSignupSubmit(e) { + e.preventDefault(); + const email = document.getElementById("signup-email").value; + const password = document.getElementById("signup-password").value; + const confirm = document.getElementById("signup-confirm").value; + const errorEl = document.getElementById("signup-error"); + const submitBtn = e.target.querySelector('button[type="submit"]'); + + if (password !== confirm) { + errorEl.textContent = t("authPasswordMismatch") || "Passwords do not match"; + errorEl.classList.remove("hidden"); + return; + } + + // Disable button while processing + submitBtn.disabled = true; + + const { error } = await authModule.signUp(email, password); + + submitBtn.disabled = false; + + if (error) { + errorEl.textContent = error.message; + errorEl.classList.remove("hidden"); + document.getElementById("signup-success")?.classList.add("hidden"); + } else { + errorEl.classList.add("hidden"); + // Show success message + const successEl = document.getElementById("signup-success"); + successEl?.classList.remove("hidden"); + // Hide the form fields and button + e.target.querySelectorAll(".form-field, button[type='submit']").forEach(el => { + el.classList.add("hidden"); + }); + } +} + +async function handleResetSubmit(e) { + e.preventDefault(); + const email = document.getElementById("reset-email").value; + const errorEl = document.getElementById("reset-error"); + const successEl = document.getElementById("reset-success"); + const submitBtn = e.target.querySelector('button[type="submit"]'); + + // Disable button while processing + submitBtn.disabled = true; + + const { error } = await authModule.resetPassword(email); + + submitBtn.disabled = false; + + if (error) { + errorEl.textContent = error.message; + errorEl.classList.remove("hidden"); + successEl.classList.add("hidden"); + } else { + errorEl.classList.add("hidden"); + successEl.classList.remove("hidden"); + } +} + +function switchForm(formName) { + const loginForm = document.getElementById("login-form"); + const signupForm = document.getElementById("signup-form"); + const resetForm = document.getElementById("reset-form"); + const showSignup = document.getElementById("show-signup"); + const showLogin = document.getElementById("show-login"); + const showReset = document.getElementById("show-reset"); + const titleEl = document.getElementById("auth-dialog-title"); + const socialSection = document.querySelector(".auth-social"); + + // Hide all forms + loginForm?.classList.add("hidden"); + signupForm?.classList.add("hidden"); + resetForm?.classList.add("hidden"); + + // Show the selected form + if (formName === "login") { + loginForm?.classList.remove("hidden"); + showSignup?.classList.remove("hidden"); + showLogin?.classList.add("hidden"); + showReset?.classList.remove("hidden"); + socialSection?.classList.remove("hidden"); + if (titleEl) titleEl.setAttribute("data-i18n", "authLogin"); + } else if (formName === "signup") { + signupForm?.classList.remove("hidden"); + // Reset signup form to initial state (in case it was showing success) + signupForm?.querySelectorAll(".form-field, button[type='submit']").forEach(el => { + el.classList.remove("hidden"); + }); + signupForm?.reset(); + showSignup?.classList.add("hidden"); + showLogin?.classList.remove("hidden"); + showReset?.classList.add("hidden"); + socialSection?.classList.remove("hidden"); + if (titleEl) titleEl.setAttribute("data-i18n", "authSignUp"); + } else if (formName === "reset") { + resetForm?.classList.remove("hidden"); + showSignup?.classList.add("hidden"); + showLogin?.classList.remove("hidden"); + showReset?.classList.add("hidden"); + socialSection?.classList.add("hidden"); + if (titleEl) titleEl.setAttribute("data-i18n", "authResetPassword"); + } + + // Clear error messages + document.getElementById("login-error")?.classList.add("hidden"); + document.getElementById("signup-error")?.classList.add("hidden"); + document.getElementById("reset-error")?.classList.add("hidden"); + document.getElementById("reset-success")?.classList.add("hidden"); + + // Apply translations to updated elements + applyTranslations(); +} diff --git a/src/i18n.js b/src/i18n.js index 7945ab2..f9e47fa 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -129,7 +129,7 @@ const translations = { landingBenefit3Title: "Master Real Skills", landingBenefit3Text: "Learn CSS, HTML, and Tailwind the way professionals use them—through hands-on exercises and reference guides.", landingBenefit4Title: "Free & Open Source", - landingBenefit4Text: "No account, no paywall, no tracking. Your progress stays in your browser. The code is open for everyone.", + landingBenefit4Text: "No paywall, no tracking. Optional account for cloud sync across devices. The code is open for everyone.", landingPathsTitle: "Explore Learning Paths", landingCssDesc: "Styling, layout, and animations", landingHtmlDesc: "Semantic markup and native elements", @@ -149,6 +149,8 @@ const translations = { comingSoonJsText: "Interactive JavaScript lessons with live code execution and DOM manipulation.", comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "React, Vue, and Svelte basics. Build real components step by step.", + comingSoonChallengesTitle: "Code Challenges", + comingSoonChallengesText: "Test your skills with timed puzzles. Compete on leaderboards and earn ranks.", // Newsletter newsletterText: "Want to know when new features launch?", @@ -168,10 +170,58 @@ const translations = { footerSupport: "Support", footerSupportText: "Help keep CODE CRISPIES free and open source.", footerLicense: "Released into the public domain.", + footerPrivacy: "Privacy Policy", + footerImprint: "Imprint", + + // Privacy Policy + privacyTitle: "Privacy Policy", + privacyIntro: "CODE CRISPIES respects your privacy. This policy explains what data we collect and how we use it.", + privacyLocalTitle: "Local Storage", + privacyLocalText: "Your learning progress, code, and settings are stored locally in your browser. This data never leaves your device unless you create an account.", + privacyAccountTitle: "Account Data (Optional)", + privacyAccountText: "If you create an account, we store your email address and encrypted password to enable cloud sync. Your progress data is synced to our servers (Supabase) so you can access it across devices.", + privacyNewsletterTitle: "Newsletter (Optional)", + privacyNewsletterText: "If you subscribe to our newsletter, we store your email address to send updates about new features. You can unsubscribe anytime.", + privacyNoTrackingTitle: "No Tracking", + privacyNoTrackingText: "We do not use cookies for tracking, analytics, or advertising. We do not share your data with third parties.", + privacyRightsTitle: "Your Rights (GDPR)", + privacyRightsText: "You can delete your account and all associated data at any time from the sidebar menu. For questions or data requests, contact us at mail@codecrispi.es", + privacyUpdated: "Last updated: January 2025", + + // Imprint + imprintTitle: "Imprint", + imprintResponsibleTitle: "Responsible for content", + imprintContactTitle: "Contact", + imprintDisclaimerTitle: "Disclaimer", + imprintDisclaimerText: "CODE CRISPIES is provided \"as is\" without warranty. We are not liable for any damages arising from the use of this service. External links are provided for convenience; we are not responsible for their content.", // Help Dialog Support supportTitle: "Support the Project", - supportText: "Help keep CODE CRISPIES free and open source." + supportText: "Help keep CODE CRISPIES free and open source.", + + // Auth + authLogin: "Log In", + authSignUp: "Sign Up", + authLogout: "Log Out", + authEmail: "Email", + authPassword: "Password", + authConfirmPassword: "Confirm Password", + authNoAccount: "Don't have an account? Sign up", + authHaveAccount: "Already have an account? Log in", + authForgotPassword: "Forgot password?", + authResetPassword: "Reset Password", + authResetInstructions: "Enter your email to receive a password reset link.", + authSendReset: "Send Reset Link", + authResetSent: "Check your email for the reset link.", + authOrContinueWith: "or continue with", + authPasswordMismatch: "Passwords do not match", + authSignupSuccess: "Account created! Check your email to confirm.", + authAccount: "Account", + authSyncHint: "Log in to sync progress across devices", + authDeleteAccount: "Delete Account", + authDeleteDialogTitle: "Delete Account", + authDeleteDialogText: "Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.", + authDeleteConfirm: "Delete Account" }, de: { @@ -303,7 +353,7 @@ const translations = { landingBenefit3Title: "Echte Fähigkeiten", landingBenefit3Text: "Lerne CSS, HTML und Tailwind so, wie Profis sie nutzen – durch praktische Übungen und Referenzanleitungen.", landingBenefit4Title: "Frei & Open Source", - landingBenefit4Text: "Kein Konto, keine Paywall, kein Tracking. Dein Fortschritt bleibt in deinem Browser. Der Code ist offen für alle.", + landingBenefit4Text: "Keine Paywall, kein Tracking. Optionales Konto für Cloud-Sync über Geräte hinweg. Der Code ist offen für alle.", landingPathsTitle: "Lernpfade entdecken", landingCssDesc: "Styling, Layout und Animationen", landingHtmlDesc: "Semantisches Markup und native Elemente", @@ -323,6 +373,8 @@ const translations = { comingSoonJsText: "Interaktive JavaScript-Lektionen mit Live-Code-Ausführung und DOM-Manipulation.", comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "React, Vue und Svelte Grundlagen. Baue echte Komponenten Schritt für Schritt.", + comingSoonChallengesTitle: "Code-Herausforderungen", + comingSoonChallengesText: "Teste deine Fähigkeiten mit zeitgesteuerten Rätseln. Kämpfe auf Bestenlisten und steige im Rang auf.", // Newsletter newsletterText: "Möchtest du erfahren, wenn neue Funktionen erscheinen?", @@ -342,10 +394,54 @@ const translations = { footerSupport: "Unterstützen", footerSupportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten.", footerLicense: "Gemeinfrei (Public Domain).", + footerPrivacy: "Datenschutz", + footerImprint: "Impressum", + privacyTitle: "Datenschutzerklärung", + privacyIntro: "CODE CRISPIES respektiert deine Privatsphäre. Diese Richtlinie erklärt, welche Daten wir sammeln und wie wir sie verwenden.", + privacyLocalTitle: "Lokale Speicherung", + privacyLocalText: "Dein Lernfortschritt, Code und Einstellungen werden lokal in deinem Browser gespeichert. Diese Daten verlassen dein Gerät nicht, es sei denn, du erstellst ein Konto.", + privacyAccountTitle: "Kontodaten (Optional)", + privacyAccountText: "Wenn du ein Konto erstellst, speichern wir deine E-Mail-Adresse und dein verschlüsseltes Passwort für die Cloud-Synchronisierung.", + privacyNewsletterTitle: "Newsletter (Optional)", + privacyNewsletterText: "Wenn du unseren Newsletter abonnierst, speichern wir deine E-Mail-Adresse für Updates. Du kannst dich jederzeit abmelden.", + privacyNoTrackingTitle: "Kein Tracking", + privacyNoTrackingText: "Wir verwenden keine Cookies für Tracking, Analytik oder Werbung. Wir teilen deine Daten nicht mit Dritten.", + privacyRightsTitle: "Deine Rechte (DSGVO)", + privacyRightsText: "Du kannst dein Konto und alle zugehörigen Daten jederzeit über das Seitenmenü löschen. Bei Fragen: mail@codecrispi.es", + privacyUpdated: "Zuletzt aktualisiert: Januar 2025", + imprintTitle: "Impressum", + imprintResponsibleTitle: "Verantwortlich für den Inhalt", + imprintContactTitle: "Kontakt", + imprintDisclaimerTitle: "Haftungsausschluss", + imprintDisclaimerText: "CODE CRISPIES wird ohne Gewährleistung bereitgestellt. Wir haften nicht für Schäden, die durch die Nutzung entstehen.", // Help Dialog Support supportTitle: "Projekt unterstützen", - supportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten." + supportText: "Hilf mit, CODE CRISPIES kostenlos und Open Source zu halten.", + + // Auth + authLogin: "Anmelden", + authSignUp: "Registrieren", + authLogout: "Abmelden", + authEmail: "E-Mail", + authPassword: "Passwort", + authConfirmPassword: "Passwort bestätigen", + authNoAccount: "Noch kein Konto? Registrieren", + authHaveAccount: "Bereits ein Konto? Anmelden", + authForgotPassword: "Passwort vergessen?", + authResetPassword: "Passwort zurücksetzen", + authResetInstructions: "Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen zu erhalten.", + authSendReset: "Link senden", + authResetSent: "Prüfe deine E-Mails für den Reset-Link.", + authOrContinueWith: "oder weiter mit", + authPasswordMismatch: "Passwörter stimmen nicht überein", + authSignupSuccess: "Konto erstellt! Überprüfe deine E-Mail zur Bestätigung.", + authAccount: "Konto", + authSyncHint: "Anmelden, um Fortschritt geräteübergreifend zu synchronisieren", + authDeleteAccount: "Konto löschen", + authDeleteDialogTitle: "Konto löschen", + authDeleteDialogText: "Bist du sicher, dass du dein Konto löschen möchtest? Dein gesamter Cloud-Fortschritt wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", + authDeleteConfirm: "Konto löschen" }, // Polish @@ -477,7 +573,7 @@ const translations = { landingBenefit3Text: "Naucz się CSS, HTML i Tailwind tak, jak używają ich profesjonaliści – poprzez praktyczne ćwiczenia i przewodniki referencyjne.", landingBenefit4Title: "Darmowe i Open Source", - landingBenefit4Text: "Bez konta, bez paywalla, bez śledzenia. Twój postęp zostaje w przeglądarce. Kod jest otwarty dla wszystkich.", + landingBenefit4Text: "Bez paywalla, bez śledzenia. Opcjonalne konto do synchronizacji w chmurze. Kod jest otwarty dla wszystkich.", landingPathsTitle: "Odkryj ścieżki nauki", landingCssDesc: "Stylowanie, układy i animacje", landingHtmlDesc: "Semantyczne znaczniki i natywne elementy", @@ -497,6 +593,8 @@ const translations = { comingSoonJsText: "Interaktywne lekcje JavaScript z wykonywaniem kodu na żywo i manipulacją DOM.", comingSoonFrameworksTitle: "Frameworki", comingSoonFrameworksText: "Podstawy React, Vue i Svelte. Buduj prawdziwe komponenty krok po kroku.", + comingSoonChallengesTitle: "Wyzwania kodowania", + comingSoonChallengesText: "Sprawdź swoje umiejętności w zadaniach na czas. Rywalizuj na tablicach wyników i zdobywaj rangi.", // Newsletter newsletterText: "Chcesz wiedzieć, kiedy pojawią się nowe funkcje?", @@ -516,10 +614,54 @@ const translations = { footerSupport: "Wsparcie", footerSupportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source.", footerLicense: "Udostępnione jako domena publiczna.", + footerPrivacy: "Polityka prywatności", + footerImprint: "Informacje prawne", + privacyTitle: "Polityka prywatności", + privacyIntro: "CODE CRISPIES szanuje Twoją prywatność. Ta polityka wyjaśnia, jakie dane zbieramy i jak je wykorzystujemy.", + privacyLocalTitle: "Lokalne przechowywanie", + privacyLocalText: "Twój postęp, kod i ustawienia są przechowywane lokalnie w przeglądarce. Dane te nie opuszczają urządzenia, chyba że utworzysz konto.", + privacyAccountTitle: "Dane konta (opcjonalne)", + privacyAccountText: "Jeśli utworzysz konto, przechowujemy Twój e-mail i zaszyfrowane hasło do synchronizacji w chmurze.", + privacyNewsletterTitle: "Newsletter (opcjonalnie)", + privacyNewsletterText: "Jeśli zapiszesz się do newslettera, przechowujemy Twój e-mail do wysyłania aktualizacji. Możesz się wypisać w dowolnym momencie.", + privacyNoTrackingTitle: "Brak śledzenia", + privacyNoTrackingText: "Nie używamy plików cookie do śledzenia, analityki ani reklam. Nie udostępniamy danych osobom trzecim.", + privacyRightsTitle: "Twoje prawa (RODO)", + privacyRightsText: "Możesz usunąć swoje konto i wszystkie powiązane dane w dowolnym momencie z menu bocznego. Pytania: mail@codecrispi.es", + privacyUpdated: "Ostatnia aktualizacja: styczeń 2025", + imprintTitle: "Informacje prawne", + imprintResponsibleTitle: "Odpowiedzialny za treść", + imprintContactTitle: "Kontakt", + imprintDisclaimerTitle: "Zastrzeżenie", + imprintDisclaimerText: "CODE CRISPIES jest dostarczany bez gwarancji. Nie ponosimy odpowiedzialności za szkody wynikające z korzystania z usługi.", // Help Dialog Support supportTitle: "Wesprzyj projekt", - supportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source." + supportText: "Pomóż utrzymać CODE CRISPIES darmowym i open source.", + + // Auth + authLogin: "Zaloguj się", + authSignUp: "Zarejestruj się", + authLogout: "Wyloguj się", + authEmail: "E-mail", + authPassword: "Hasło", + authConfirmPassword: "Potwierdź hasło", + authNoAccount: "Nie masz konta? Zarejestruj się", + authHaveAccount: "Masz już konto? Zaloguj się", + authForgotPassword: "Zapomniałeś hasła?", + authResetPassword: "Resetuj hasło", + authResetInstructions: "Podaj swój e-mail, aby otrzymać link do resetowania hasła.", + authSendReset: "Wyślij link", + authResetSent: "Sprawdź e-mail, aby znaleźć link do resetowania.", + authOrContinueWith: "lub kontynuuj przez", + authPasswordMismatch: "Hasła nie są zgodne", + authSignupSuccess: "Konto utworzone! Sprawdź e-mail, aby potwierdzić.", + authAccount: "Konto", + authSyncHint: "Zaloguj się, aby synchronizować postępy między urządzeniami", + authDeleteAccount: "Usuń konto", + authDeleteDialogTitle: "Usuń konto", + authDeleteDialogText: "Czy na pewno chcesz usunąć swoje konto? Cały postęp w chmurze zostanie trwale usunięty. Tej operacji nie można cofnąć.", + authDeleteConfirm: "Usuń konto" }, // Spanish @@ -653,7 +795,7 @@ const translations = { landingBenefit3Title: "Habilidades reales", landingBenefit3Text: "Aprende CSS, HTML y Tailwind como los usan los profesionales—a través de ejercicios prácticos y guías de referencia.", landingBenefit4Title: "Gratis y Open Source", - landingBenefit4Text: "Sin cuenta, sin paywall, sin rastreo. Tu progreso se queda en tu navegador. El código está abierto para todos.", + landingBenefit4Text: "Sin paywall, sin rastreo. Cuenta opcional para sincronización en la nube. El código está abierto para todos.", landingPathsTitle: "Explora rutas de aprendizaje", landingCssDesc: "Estilos, diseño y animaciones", landingHtmlDesc: "Marcado semántico y elementos nativos", @@ -673,6 +815,8 @@ const translations = { comingSoonJsText: "Lecciones interactivas de JavaScript con ejecución de código en vivo y manipulación del DOM.", comingSoonFrameworksTitle: "Frameworks", comingSoonFrameworksText: "Fundamentos de React, Vue y Svelte. Construye componentes reales paso a paso.", + comingSoonChallengesTitle: "Desafíos de código", + comingSoonChallengesText: "Pon a prueba tus habilidades con puzzles cronometrados. Compite en clasificaciones y gana rangos.", // Newsletter newsletterText: "¿Quieres saber cuando se lancen nuevas funciones?", @@ -692,10 +836,54 @@ const translations = { footerSupport: "Apoyar", footerSupportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto.", footerLicense: "Liberado al dominio público.", + footerPrivacy: "Política de privacidad", + footerImprint: "Aviso legal", + privacyTitle: "Política de privacidad", + privacyIntro: "CODE CRISPIES respeta tu privacidad. Esta política explica qué datos recopilamos y cómo los usamos.", + privacyLocalTitle: "Almacenamiento local", + privacyLocalText: "Tu progreso, código y configuración se almacenan localmente en tu navegador. Estos datos no salen de tu dispositivo a menos que crees una cuenta.", + privacyAccountTitle: "Datos de cuenta (opcional)", + privacyAccountText: "Si creas una cuenta, almacenamos tu email y contraseña encriptada para la sincronización en la nube.", + privacyNewsletterTitle: "Newsletter (opcional)", + privacyNewsletterText: "Si te suscribes al newsletter, almacenamos tu email para enviar actualizaciones. Puedes cancelar en cualquier momento.", + privacyNoTrackingTitle: "Sin rastreo", + privacyNoTrackingText: "No usamos cookies para rastreo, analíticas o publicidad. No compartimos tus datos con terceros.", + privacyRightsTitle: "Tus derechos (RGPD)", + privacyRightsText: "Puedes eliminar tu cuenta y todos los datos asociados en cualquier momento desde el menú lateral. Contacto: mail@codecrispi.es", + privacyUpdated: "Última actualización: enero 2025", + imprintTitle: "Aviso legal", + imprintResponsibleTitle: "Responsable del contenido", + imprintContactTitle: "Contacto", + imprintDisclaimerTitle: "Descargo de responsabilidad", + imprintDisclaimerText: "CODE CRISPIES se proporciona sin garantía. No somos responsables de daños derivados del uso de este servicio.", // Help Dialog Support supportTitle: "Apoyar el proyecto", - supportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto." + supportText: "Ayuda a mantener CODE CRISPIES gratis y de código abierto.", + + // Auth + authLogin: "Iniciar sesión", + authSignUp: "Registrarse", + authLogout: "Cerrar sesión", + authEmail: "Correo electrónico", + authPassword: "Contraseña", + authConfirmPassword: "Confirmar contraseña", + authNoAccount: "¿No tienes cuenta? Regístrate", + authHaveAccount: "¿Ya tienes cuenta? Inicia sesión", + authForgotPassword: "¿Olvidaste tu contraseña?", + authResetPassword: "Restablecer contraseña", + authResetInstructions: "Ingresa tu correo para recibir un enlace de restablecimiento.", + authSendReset: "Enviar enlace", + authResetSent: "Revisa tu correo para el enlace de restablecimiento.", + authOrContinueWith: "o continúa con", + authPasswordMismatch: "Las contraseñas no coinciden", + authSignupSuccess: "¡Cuenta creada! Revisa tu correo para confirmar.", + authAccount: "Cuenta", + authSyncHint: "Inicia sesión para sincronizar tu progreso entre dispositivos", + authDeleteAccount: "Eliminar cuenta", + authDeleteDialogTitle: "Eliminar cuenta", + authDeleteDialogText: "¿Estás seguro de que quieres eliminar tu cuenta? Todo tu progreso en la nube se eliminará permanentemente. Esta acción no se puede deshacer.", + authDeleteConfirm: "Eliminar cuenta" }, // Arabic @@ -824,7 +1012,7 @@ const translations = { landingBenefit3Title: "مهارات حقيقية", landingBenefit3Text: "تعلم CSS و HTML و Tailwind بالطريقة التي يستخدمها المحترفون—من خلال تمارين عملية وأدلة مرجعية.", landingBenefit4Title: "مجاني ومفتوح المصدر", - landingBenefit4Text: "بدون حساب، بدون حواجز دفع، بدون تتبع. تقدمك يبقى في متصفحك. الكود مفتوح للجميع.", + landingBenefit4Text: "بدون حواجز دفع، بدون تتبع. حساب اختياري للمزامنة السحابية. الكود مفتوح للجميع.", landingPathsTitle: "استكشف مسارات التعلم", landingCssDesc: "التنسيق والتخطيط والرسوم المتحركة", landingHtmlDesc: "الترميز الدلالي والعناصر الأصلية", @@ -844,6 +1032,8 @@ const translations = { comingSoonJsText: "دروس تفاعلية في JavaScript مع تنفيذ مباشر للكود والتعامل مع DOM.", comingSoonFrameworksTitle: "أطر العمل", comingSoonFrameworksText: "أساسيات React وVue وSvelte. ابنِ مكونات حقيقية خطوة بخطوة.", + comingSoonChallengesTitle: "تحديات البرمجة", + comingSoonChallengesText: "اختبر مهاراتك مع ألغاز موقوتة. تنافس على لوحات المتصدرين واكسب الرتب.", // Newsletter newsletterText: "هل تريد معرفة متى تُطلق ميزات جديدة؟", @@ -863,10 +1053,54 @@ const translations = { footerSupport: "الدعم", footerSupportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر.", footerLicense: "مُطلق للملكية العامة.", + footerPrivacy: "سياسة الخصوصية", + footerImprint: "البيانات القانونية", + privacyTitle: "سياسة الخصوصية", + privacyIntro: "CODE CRISPIES يحترم خصوصيتك. توضح هذه السياسة البيانات التي نجمعها وكيف نستخدمها.", + privacyLocalTitle: "التخزين المحلي", + privacyLocalText: "يتم تخزين تقدمك وكودك وإعداداتك محليًا في متصفحك. لا تغادر هذه البيانات جهازك إلا إذا أنشأت حسابًا.", + privacyAccountTitle: "بيانات الحساب (اختياري)", + privacyAccountText: "إذا أنشأت حسابًا، نخزن بريدك الإلكتروني وكلمة مرورك المشفرة للمزامنة السحابية.", + privacyNewsletterTitle: "النشرة الإخبارية (اختياري)", + privacyNewsletterText: "إذا اشتركت في نشرتنا الإخبارية، نخزن بريدك الإلكتروني لإرسال التحديثات. يمكنك إلغاء الاشتراك في أي وقت.", + privacyNoTrackingTitle: "بدون تتبع", + privacyNoTrackingText: "لا نستخدم ملفات تعريف الارتباط للتتبع أو التحليلات أو الإعلانات. لا نشارك بياناتك مع أطراف ثالثة.", + privacyRightsTitle: "حقوقك (GDPR)", + privacyRightsText: "يمكنك حذف حسابك وجميع البيانات المرتبطة في أي وقت من القائمة الجانبية. للاستفسارات: mail@codecrispi.es", + privacyUpdated: "آخر تحديث: يناير 2025", + imprintTitle: "البيانات القانونية", + imprintResponsibleTitle: "المسؤول عن المحتوى", + imprintContactTitle: "التواصل", + imprintDisclaimerTitle: "إخلاء المسؤولية", + imprintDisclaimerText: "يتم تقديم CODE CRISPIES دون ضمان. نحن غير مسؤولين عن أي أضرار ناتجة عن استخدام هذه الخدمة.", // Help Dialog Support supportTitle: "ادعم المشروع", - supportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر." + supportText: "ساعد في إبقاء CODE CRISPIES مجانيًا ومفتوح المصدر.", + + // Auth + authLogin: "تسجيل الدخول", + authSignUp: "إنشاء حساب", + authLogout: "تسجيل الخروج", + authEmail: "البريد الإلكتروني", + authPassword: "كلمة المرور", + authConfirmPassword: "تأكيد كلمة المرور", + authNoAccount: "ليس لديك حساب؟ سجّل الآن", + authHaveAccount: "لديك حساب بالفعل؟ سجّل الدخول", + authForgotPassword: "نسيت كلمة المرور؟", + authResetPassword: "إعادة تعيين كلمة المرور", + authResetInstructions: "أدخل بريدك الإلكتروني لتلقي رابط إعادة التعيين.", + authSendReset: "إرسال الرابط", + authResetSent: "تحقق من بريدك الإلكتروني للحصول على رابط إعادة التعيين.", + authOrContinueWith: "أو تابع باستخدام", + authPasswordMismatch: "كلمات المرور غير متطابقة", + authSignupSuccess: "تم إنشاء الحساب! تحقق من بريدك الإلكتروني للتأكيد.", + authAccount: "الحساب", + authSyncHint: "سجّل الدخول لمزامنة التقدم عبر الأجهزة", + authDeleteAccount: "حذف الحساب", + authDeleteDialogTitle: "حذف الحساب", + authDeleteDialogText: "هل أنت متأكد أنك تريد حذف حسابك؟ سيتم حذف جميع تقدمك في السحابة نهائيًا. لا يمكن التراجع عن هذا الإجراء.", + authDeleteConfirm: "حذف الحساب" }, // Ukrainian @@ -997,7 +1231,7 @@ const translations = { landingBenefit3Title: "Реальні навички", landingBenefit3Text: "Вивчай CSS, HTML та Tailwind так, як їх використовують професіонали—через практичні вправи та довідники.", landingBenefit4Title: "Безкоштовно та Open Source", - landingBenefit4Text: "Без акаунту, без paywall, без відстеження. Твій прогрес залишається у браузері. Код відкритий для всіх.", + landingBenefit4Text: "Без paywall, без відстеження. Опціональний акаунт для хмарної синхронізації. Код відкритий для всіх.", landingPathsTitle: "Досліджуй шляхи навчання", landingCssDesc: "Стилізація, макети та анімації", landingHtmlDesc: "Семантична розмітка та нативні елементи", @@ -1017,6 +1251,8 @@ const translations = { comingSoonJsText: "Інтерактивні уроки JavaScript з виконанням коду в реальному часі та маніпуляцією DOM.", comingSoonFrameworksTitle: "Фреймворки", comingSoonFrameworksText: "Основи React, Vue та Svelte. Створюй справжні компоненти крок за кроком.", + comingSoonChallengesTitle: "Кодові виклики", + comingSoonChallengesText: "Перевір свої навички в завданнях на час. Змагайся в рейтингах і здобувай ранги.", // Newsletter newsletterText: "Хочете дізнатися, коли з'являться нові функції?", @@ -1036,10 +1272,54 @@ const translations = { footerSupport: "Підтримка", footerSupportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом.", footerLicense: "Передано у суспільне надбання.", + footerPrivacy: "Політика конфіденційності", + footerImprint: "Правова інформація", + privacyTitle: "Політика конфіденційності", + privacyIntro: "CODE CRISPIES поважає твою приватність. Ця політика пояснює, які дані ми збираємо і як їх використовуємо.", + privacyLocalTitle: "Локальне сховище", + privacyLocalText: "Твій прогрес, код та налаштування зберігаються локально у браузері. Ці дані не залишають твій пристрій, якщо ти не створюєш акаунт.", + privacyAccountTitle: "Дані акаунту (необов'язково)", + privacyAccountText: "Якщо ти створюєш акаунт, ми зберігаємо твою електронну пошту та зашифрований пароль для хмарної синхронізації.", + privacyNewsletterTitle: "Розсилка (необов'язково)", + privacyNewsletterText: "Якщо ти підписуєшся на розсилку, ми зберігаємо твою пошту для надсилання оновлень. Ти можеш відписатися в будь-який час.", + privacyNoTrackingTitle: "Без відстеження", + privacyNoTrackingText: "Ми не використовуємо файли cookie для відстеження, аналітики чи реклами. Ми не ділимося твоїми даними з третіми сторонами.", + privacyRightsTitle: "Твої права (GDPR)", + privacyRightsText: "Ти можеш видалити свій акаунт і всі пов'язані дані в будь-який час з бічного меню. Питання: mail@codecrispi.es", + privacyUpdated: "Останнє оновлення: січень 2025", + imprintTitle: "Правова інформація", + imprintResponsibleTitle: "Відповідальний за вміст", + imprintContactTitle: "Контакт", + imprintDisclaimerTitle: "Застереження", + imprintDisclaimerText: "CODE CRISPIES надається без гарантій. Ми не несемо відповідальності за збитки, що виникають внаслідок використання цього сервісу.", // Help Dialog Support supportTitle: "Підтримати проєкт", - supportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом." + supportText: "Допоможи зберегти CODE CRISPIES безкоштовним та з відкритим кодом.", + + // Auth + authLogin: "Увійти", + authSignUp: "Зареєструватися", + authLogout: "Вийти", + authEmail: "Електронна пошта", + authPassword: "Пароль", + authConfirmPassword: "Підтвердити пароль", + authNoAccount: "Немає акаунту? Зареєструйся", + authHaveAccount: "Вже є акаунт? Увійди", + authForgotPassword: "Забули пароль?", + authResetPassword: "Скинути пароль", + authResetInstructions: "Введи свою електронну пошту, щоб отримати посилання для скидання.", + authSendReset: "Надіслати посилання", + authResetSent: "Перевір електронну пошту для посилання на скидання.", + authOrContinueWith: "або продовжити через", + authPasswordMismatch: "Паролі не співпадають", + authSignupSuccess: "Акаунт створено! Перевір електронну пошту для підтвердження.", + authAccount: "Акаунт", + authSyncHint: "Увійди, щоб синхронізувати прогрес між пристроями", + authDeleteAccount: "Видалити акаунт", + authDeleteDialogTitle: "Видалити акаунт", + authDeleteDialogText: "Ти впевнений, що хочеш видалити свій акаунт? Весь твій хмарний прогрес буде видалено назавжди. Цю дію неможливо скасувати.", + authDeleteConfirm: "Видалити акаунт" } }; diff --git a/src/impl/LessonEngine.js b/src/impl/LessonEngine.js index e080654..c0de5ac 100644 --- a/src/impl/LessonEngine.js +++ b/src/impl/LessonEngine.js @@ -4,6 +4,20 @@ */ import { validateUserCode } from "../helpers/validator.js"; +// Auth sync - lazy loaded to avoid circular dependencies +let authModule = null; +async function getAuthModule() { + if (!authModule) { + try { + authModule = await import("../auth.js"); + } catch (e) { + // Auth module not available, skip cloud sync + return null; + } + } + return authModule; +} + export class LessonEngine { constructor() { this.currentLesson = null; @@ -484,7 +498,7 @@ export class LessonEngine { } /** - * Save progress to localStorage + * Save progress to localStorage and optionally sync to cloud */ saveUserProgress() { try { @@ -494,11 +508,24 @@ export class LessonEngine { timestamp: new Date().toISOString() }; localStorage.setItem("codeCrispies.progress", JSON.stringify(progressData)); + + // Trigger cloud sync if logged in (debounced) + this.triggerCloudSync(); } catch (e) { console.error("Error saving progress:", e); } } + /** + * Trigger cloud sync if user is logged in (debounced) + */ + async triggerCloudSync() { + const auth = await getAuthModule(); + if (auth?.isLoggedIn()) { + auth.debouncedSyncToCloud(); + } + } + /** * Load progress from localStorage */ @@ -521,11 +548,14 @@ export class LessonEngine { } /** - * Save user code to localStorage + * Save user code to localStorage and optionally sync to cloud */ saveUserCodeToStorage() { try { localStorage.setItem("codeCrispies.userCode", JSON.stringify(Array.from(this.userCodeMap.entries()))); + + // Trigger cloud sync if logged in (debounced) + this.triggerCloudSync(); } catch (e) { console.error("Error saving user code:", e); } diff --git a/src/index.html b/src/index.html index d644a59..c0a5c2f 100644 --- a/src/index.html +++ b/src/index.html @@ -77,6 +77,8 @@ Tailwind Reference + + @@ -139,7 +141,7 @@

Free & Open Source

- No account, no paywall, no tracking. Your progress stays in your browser. The code is open for everyone. + No paywall, no tracking. Optional account for cloud sync across devices. The code is open for everyone.

@@ -172,13 +174,6 @@

Coming Soon

-
- - - -

Cloud Sync

-

Sync your progress across all devices. Start on desktop, continue on tablet.

-
@@ -200,6 +195,13 @@

Frameworks

React, Vue, and Svelte basics. Build real components step by step.

+
+ + + +

Code Challenges

+

Test your skills with timed puzzles. Compete on leaderboards and earn ranks.

+
@@ -306,6 +313,11 @@ @@ -354,6 +366,11 @@ @@ -462,6 +479,17 @@ + + +
+

Delete Account

+ +
+
+

Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.

+ +
+ + +
+
+
+
@@ -631,6 +675,136 @@
+ + + +
+

Privacy Policy

+ +
+ +
+ + + +
+

Imprint

+ +
+ +
+ + + +
+

Log In

+ +
+
+ +
+
+ + +
+
+ + +
+ + +
+ + + + + + + + +
+
or continue with
+
+ + +
+
+ + + +
+
diff --git a/src/main.css b/src/main.css index a4348ca..3018d8a 100644 --- a/src/main.css +++ b/src/main.css @@ -981,6 +981,7 @@ nav.sidebar-section { flex: 1; overflow-y: auto; min-height: 0; + padding-bottom: var(--spacing-md); } .sidebar-section h4 { @@ -1238,6 +1239,28 @@ button.lesson-list-item { color: var(--danger-color); } +.btn-danger { + background: var(--danger-color); + color: white; + border-color: var(--danger-color); +} + +.btn-danger:hover { + background: #c82333; + border-color: #bd2130; +} + +.btn-text.btn-danger { + background: transparent; + color: var(--danger-color); + border: none; +} + +.btn-text.btn-danger:hover { + color: #c82333; + text-decoration: underline; +} + #reset-code-btn { background: var(--section-color, var(--primary-color)); color: white; @@ -1493,6 +1516,257 @@ input:checked + .toggle-slider::before { flex-direction: row-reverse; } +/* ================= AUTH DIALOG ================= */ +.auth-dialog { + max-width: 400px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form-field label { + font-size: 0.875rem; + font-weight: 500; + color: var(--light-text); +} + +.form-field input { + padding: 0.75rem 1rem; + border: 2px solid var(--border-color); + border-radius: var(--border-radius-md); + font-size: 1rem; + font-family: var(--font-main); + transition: border-color 0.2s; +} + +.form-field input:focus { + outline: none; + border-color: var(--primary-color); +} + +.btn-full { + width: 100%; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} + +.auth-error { + color: var(--danger-color); + font-size: 0.875rem; + margin: 0; +} + +.auth-success { + color: var(--success-color); + font-size: 0.875rem; + margin: 0; +} + +.auth-instructions { + color: var(--light-text); + font-size: 0.9rem; + margin-bottom: var(--spacing-sm); +} + +.auth-links { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--border-color); +} + +.auth-links .btn-text { + font-size: 0.875rem; +} + +/* Social Login */ +.auth-social { + margin-top: var(--spacing-lg); +} + +.auth-divider { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); + color: var(--light-text); + font-size: 0.875rem; +} + +.auth-divider::before, +.auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-color); +} + +.auth-social-buttons { + display: flex; + gap: 0.75rem; +} + +.btn-social { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: 2px solid var(--border-color); + border-radius: var(--border-radius-md); + background: var(--panel-bg); + font-weight: 500; + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; +} + +.btn-social:hover { + border-color: var(--primary-color); + background: var(--primary-bg-light); +} + +.social-icon { + width: 1.25rem; + height: 1.25rem; +} + +/* Header Auth Button */ +.user-menu { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-email { + font-size: 0.875rem; + color: var(--light-text); + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Sidebar Auth Box (dark design) */ +.sidebar-auth-box { + margin-top: var(--spacing-md); + padding: var(--spacing-md); + background: #1a1a2e; + border-radius: var(--border-radius-md); + color: #e0e0e0; +} + +.sidebar-auth-box h4 { + color: #fff; + margin-bottom: var(--spacing-sm); +} + +.sidebar-auth-box .btn-outline { + background: transparent; + color: #e0e0e0; + border-color: #444; +} + +.sidebar-auth-box .btn-outline:hover { + background: #2a2a4e; + border-color: #666; + color: #fff; +} + +.user-menu-sidebar { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.user-menu-sidebar .user-email { + max-width: none; + word-break: break-all; + font-size: 0.875rem; + color: #aaa; + font-weight: 500; +} + +.sidebar-auth-hint { + font-size: 0.8rem; + color: #888; + margin-top: var(--spacing-sm); +} + +/* Footer Legal Links */ +.footer-legal { + margin-top: var(--spacing-xs); + font-size: 0.85rem; +} + +.footer-legal .btn-text { + color: var(--light-text); + font-size: 0.85rem; + text-decoration: none; + padding: 0; +} + +.footer-legal .btn-text:hover { + color: var(--text-color); + text-decoration: underline; +} + +.footer-separator { + color: var(--light-text); + margin: 0 0.5rem; +} + +/* Legal Dialogs (Privacy, Imprint) */ +.legal-dialog { + max-width: 600px; +} + +.legal-content { + max-height: 60vh; + overflow-y: auto; +} + +.legal-content h4 { + margin-top: var(--spacing-md); + margin-bottom: var(--spacing-xs); + font-size: 1rem; + color: var(--text-color); +} + +.legal-content p { + margin-bottom: var(--spacing-sm); + line-height: 1.6; + color: var(--light-text); +} + +.legal-content a { + color: var(--primary-color); +} + +.legal-updated { + margin-top: var(--spacing-md); + font-size: 0.85rem; + font-style: italic; + color: var(--lighter-text); +} + /* Project Cards in Help Dialog */ .project-cards { display: flex; diff --git a/src/supabase.js b/src/supabase.js new file mode 100644 index 0000000..dc9d850 --- /dev/null +++ b/src/supabase.js @@ -0,0 +1,98 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +// Check if Supabase is configured +export const isConfigured = Boolean(supabaseUrl && supabaseAnonKey); + +// Only create client if configured +const supabase = isConfigured + ? createClient(supabaseUrl, supabaseAnonKey) + : null; + +// Auth helpers - all return null/rejected promise if not configured +export const auth = { + signUp: (email, password) => + supabase?.auth.signUp({ email, password }) ?? + Promise.resolve({ data: null, error: { message: "Not configured" } }), + + signIn: (email, password) => + supabase?.auth.signInWithPassword({ email, password }) ?? + Promise.resolve({ data: null, error: { message: "Not configured" } }), + + signOut: () => + supabase?.auth.signOut() ?? + Promise.resolve({ error: null }), + + resetPassword: (email) => + supabase?.auth.resetPasswordForEmail(email) ?? + Promise.resolve({ data: null, error: { message: "Not configured" } }), + + signInWithGoogle: () => + supabase?.auth.signInWithOAuth({ provider: "google" }) ?? + Promise.resolve({ data: null, error: { message: "Not configured" } }), + + signInWithGitHub: () => + supabase?.auth.signInWithOAuth({ provider: "github" }) ?? + Promise.resolve({ data: null, error: { message: "Not configured" } }), + + getUser: () => + supabase?.auth.getUser() ?? + Promise.resolve({ data: { user: null }, error: null }), + + onAuthStateChange: (callback) => + supabase?.auth.onAuthStateChange(callback) ?? { data: { subscription: { unsubscribe: () => {} } } }, + + deleteAccount: async () => { + if (!supabase) return { error: { message: "Not configured" } }; + const { error } = await supabase.rpc("delete_own_account"); + return { error }; + }, +}; + +// Progress sync helpers +export const progressDB = { + async load(userId) { + if (!supabase) return { data: null, error: { message: "Not configured" } }; + const { data, error } = await supabase + .from("user_progress") + .select("*") + .eq("user_id", userId) + .single(); + return { data, error }; + }, + + async save(userId, progress, userCode, settings, language) { + if (!supabase) return { error: { message: "Not configured" } }; + const { error } = await supabase.from("user_progress").upsert( + { + user_id: userId, + progress, + user_code: userCode, + settings, + language, + }, + { onConflict: "user_id" } + ); + return { error }; + }, +}; + +// Newsletter subscription helper +export const newsletter = { + async subscribe(email) { + if (!supabase) return { error: { message: "Not configured" } }; + // Use insert with ignoreDuplicates since RLS only allows INSERT + const { error } = await supabase.from("newsletter_subscribers").insert( + { + email: email.toLowerCase().trim(), + subscribed_at: new Date().toISOString(), + }, + { onConflict: "email", ignoreDuplicates: true } + ); + // Ignore duplicate email errors (already subscribed) + if (error?.code === "23505") return { error: null }; + return { error }; + }, +}; diff --git a/supabase-setup.sql b/supabase-setup.sql new file mode 100644 index 0000000..0b0ff36 --- /dev/null +++ b/supabase-setup.sql @@ -0,0 +1,53 @@ +-- CODE CRISPIES - Supabase Database Setup +-- Run this in Supabase Dashboard → SQL Editor → New Query + +-- User progress table +CREATE TABLE user_progress ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + progress JSONB NOT NULL DEFAULT '{}', + user_code JSONB NOT NULL DEFAULT '{}', + settings JSONB NOT NULL DEFAULT '{}', + language TEXT DEFAULT 'en', + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Newsletter subscribers table +CREATE TABLE newsletter_subscribers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + subscribed_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Row Level Security +ALTER TABLE user_progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY; + +-- Users can only access their own progress +CREATE POLICY "Users can CRUD own progress" + ON user_progress FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Anyone can subscribe to newsletter (public insert) +CREATE POLICY "Anyone can subscribe to newsletter" + ON newsletter_subscribers FOR INSERT + WITH CHECK (true); + +-- Function to delete own account (called via RPC) +CREATE OR REPLACE FUNCTION delete_own_account() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Delete user's progress (CASCADE should handle this, but be explicit) + DELETE FROM user_progress WHERE user_id = auth.uid(); + + -- Delete the user from auth.users + DELETE FROM auth.users WHERE id = auth.uid(); +END; +$$; diff --git a/vite.config.js b/vite.config.js index 23a2c56..8adbdeb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,6 +3,7 @@ import { defineConfig } from "vite"; export default defineConfig((env) => ({ base: "/", root: "./src", + envDir: "..", publicDir: "../public", build: { outDir: "../dist", From 9844d9ed1540606298c06f4304854253a6b1fe87 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 13:47:42 +0100 Subject: [PATCH 06/25] docs: add comprehensive roadmap for lessons and milestone system - Analyze MDN HTML/CSS documentation for new lesson ideas - Design milestone-based progress system (1, 5, 10, 20, 30, 50, 75, 100) - Document 13 inactive lesson files ready to activate - Plan 34 new lessons to reach 100 total - Include technical implementation notes --- docs/ROADMAP.md | 287 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..7e59ed7 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,287 @@ +# Code Crispies Roadmap + +## Current State + +**Total Active Lessons:** ~66 (excluding welcome, goodbye, playground) +**Target:** 100 lessons for milestone system + +### Current Module Breakdown + +| Module | Lessons | Category | +|--------|---------|----------| +| Basic Selectors | 10 | CSS | +| Colors | 4 | CSS | +| Typography | 4 | CSS | +| Box Model | 8 | CSS | +| Flexbox | 6 | CSS | +| Grid | 6 | CSS | +| Units & Variables | 4 | CSS | +| Responsive | 4 | CSS | +| Transitions & Animations | 4 | CSS | +| HTML Elements | 2 | HTML | +| Figure | 3 | HTML | +| SVG | 3 | HTML | +| Details/Summary | 3 | HTML | +| Forms Basic | 3 | HTML | +| Forms Validation | 1 | HTML | +| Tables | 1 | HTML | +| **Total** | **66** | | + +--- + +## Phase 1: Milestone Progress System + +### Design + +Replace percentage-based progress with milestone markers: + +``` +[1] [5] [10] [20] [30] [50] [75] [100] + ● ● ◐ ○ ○ ○ ○ ○ +``` + +**Milestones:** +- 1 lesson - First Step +- 5 lessons - Getting Started +- 10 lessons - Rookie +- 20 lessons - Learner +- 30 lessons - Intermediate +- 50 lessons - Halfway Hero +- 75 lessons - Advanced +- 100 lessons - Master + +### Implementation + +1. **Update `LessonEngine.getProgressStats()`** + - Add `currentMilestone` and `nextMilestone` fields + - Add `milestonesReached: number[]` + +2. **Update Progress UI** + - Replace linear progress bar with milestone dots + - Animate milestone completion + - Show current milestone badge + +3. **Add Milestone Celebration** + - Confetti/animation on reaching milestones + - Achievement unlocks in sidebar + +--- + +## Phase 2: New Lessons (34 needed to reach 100) + +### Priority 1: Expand Existing Modules (+15 lessons) + +#### CSS Colors (+3) +- Gradients (linear-gradient) +- Color functions (hsl, rgb) +- Opacity and transparency + +#### Typography (+3) +- Web fonts (@font-face) +- Text shadows +- Letter/word spacing + +#### Responsive (+3) +- Container queries +- Aspect ratio +- Clamp() for fluid typography + +#### Transitions & Animations (+3) +- Keyframe animations +- Animation timing functions +- Transform origin + +#### Tables (+3) +- Table styling (borders, spacing) +- Responsive tables +- Table accessibility + +### Priority 2: New CSS Modules (+12 lessons) + +#### Filters & Effects (4 lessons) +- CSS filters (blur, brightness, contrast) +- Backdrop filters +- Mix-blend-mode +- Box shadows advanced + +#### Positioning (4 lessons) +- Relative positioning +- Absolute positioning +- Fixed/sticky positioning +- Z-index stacking + +#### Pseudo-elements (4 lessons) +- ::before and ::after +- ::first-letter and ::first-line +- ::marker for lists +- Content property + +### Priority 3: New HTML Modules (+7 lessons) + +#### Semantic Structure (3 lessons) +- Article vs Section +- Header/Footer/Main +- Nav and Aside + +#### Media Elements (2 lessons) +- Picture element (responsive images) +- Audio/Video basics + +#### Accessibility (2 lessons) +- ARIA labels +- Skip links +- Focus management + +--- + +## MDN Topics Reference + +### CSS Topics from MDN (prioritized for interactive lessons) + +**Layout Systems:** +- [x] Flexbox (covered) +- [x] Grid (covered) +- [ ] Multi-column layout +- [ ] Positioned layout (z-index, stacking) + +**Visual Effects:** +- [x] Colors (partially covered) +- [ ] Filters (blur, brightness, contrast, drop-shadow) +- [ ] Blend modes (mix-blend-mode, background-blend-mode) +- [ ] Masking and clipping +- [ ] Shapes (shape-outside) + +**Typography:** +- [x] Basic text (covered) +- [ ] Web fonts (@font-face) +- [ ] Variable fonts +- [ ] Text decoration advanced + +**Animations:** +- [x] Transitions (covered) +- [ ] Keyframe animations +- [ ] Scroll-driven animations (experimental) +- [ ] View transitions + +**Advanced:** +- [x] Custom properties (covered in units-variables) +- [ ] Container queries +- [ ] Anchor positioning (new) +- [ ] Logical properties (for RTL support) + +### HTML Topics from MDN + +**Structural:** +- [x] Basic elements (covered) +- [x] Figure/figcaption (covered) +- [ ] Article vs section semantics +- [ ] Template element + +**Interactive:** +- [x] Details/Summary (covered) +- [x] Dialog (have JSON, not active) +- [ ] Datalist (have JSON, not active) +- [ ] Progress/Meter (have JSON, not active) + +**Forms:** +- [x] Basic forms (covered) +- [x] Validation (covered) +- [x] Fieldset (have JSON, not active) +- [ ] Input types exploration + +**Media:** +- [x] SVG basics (covered) +- [ ] Picture element +- [ ] srcset and sizes +- [ ] Audio/Video + +--- + +## Inactive Lesson Files (Ready to Activate) + +These lesson files exist but aren't in the active module list: + +| File | Lessons | Topic | +|------|---------|-------| +| 24-html-progress-meter.json | 3 | Progress/Meter elements | +| 25-html-datalist.json | 2 | Datalist for autocomplete | +| 27-html-dialog.json | 2 | Native dialog element | +| 28-html-forms-fieldset.json | 3 | Fieldset/legend grouping | +| 31-html-marquee.json | 3 | Marquee (deprecated but fun) | +| **Total** | **13** | | + +**Quick Win:** Activating these adds 13 lessons immediately → 79 total + +--- + +## Implementation Order + +### Week 1: Foundation +1. Design milestone UI component +2. Implement milestone progress system +3. Add milestone celebrations + +### Week 2: Quick Wins +4. Activate 5 inactive HTML modules (+13 lessons) +5. Test and fix translations + +### Week 3-4: New Content +6. Create Filters & Effects module (+4) +7. Create Positioning module (+4) +8. Expand existing modules (+7) + +### Final Polish +9. Reach 100 lessons milestone +10. Add milestone achievements to sidebar +11. Update landing page messaging + +--- + +## Technical Notes + +### Milestone Data Structure + +```js +const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100]; + +function getMilestoneProgress(completed) { + const reached = MILESTONES.filter(m => completed >= m); + const current = reached[reached.length - 1] || 0; + const next = MILESTONES.find(m => m > completed) || 100; + + return { + current, + next, + reached, + percentToNext: ((completed - current) / (next - current)) * 100 + }; +} +``` + +### Progress Display Update + +```html +
+
+ 1 + 5 + 10 + 20 + +
+
+
+
+ 12 of 100 lessons +
+``` + +--- + +## Success Metrics + +- [ ] 100 total lessons +- [ ] Milestone system implemented +- [ ] All 6 languages have translations +- [ ] Achievement celebrations working +- [ ] Mobile responsive milestone UI From b693013743665222a564120e21d0494fa107d8f4 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 16 Jan 2026 13:56:29 +0100 Subject: [PATCH 07/25] feat: implement milestone-based progress system and activate new lessons Progress System: - Replace percentage-based progress with milestone markers (1, 5, 10, 20, 30, 50, 75, 100) - Add visual milestone indicators with reached/current/next states - Add celebration animation when milestones are reached - Update progress bar to show progress toward next milestone - Add progressTextMilestone i18n key for all 6 languages New Lessons Activated: - HTML Dialog (native modal dialogs) - HTML Progress & Meter (indicator elements) - HTML Fieldset (form grouping) - HTML Datalist (autocomplete inputs) This adds 10 new lessons across all 6 languages, bringing total from ~66 to ~76. --- src/app.js | 43 ++++++++++++++++++++++++++--- src/auth.js | 30 +++++++++++++++++--- src/config/lessons.js | 48 ++++++++++++++++++++++++++++++++ src/i18n.js | 6 ++++ src/impl/LessonEngine.js | 22 +++++++++++++-- src/index.html | 14 ++++++++-- src/main.css | 59 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 13 deletions(-) diff --git a/src/app.js b/src/app.js index b6438f3..e7baab2 100644 --- a/src/app.js +++ b/src/app.js @@ -165,6 +165,7 @@ const elements = { sectionFooterLessonLinks: document.getElementById("section-footer-lesson-links"), progressFill: document.getElementById("progress-fill"), progressText: document.getElementById("progress-text"), + milestonesContainer: document.getElementById("milestones"), resetBtn: document.getElementById("reset-btn"), disableFeedbackToggle: document.getElementById("disable-feedback-toggle"), @@ -310,14 +311,48 @@ function showSuccessHint(message) { // ================= PROGRESS DISPLAY ================= +// Track last milestone to detect new achievements +let lastMilestoneReached = 0; + function updateProgressDisplay() { const stats = lessonEngine.getProgressStats(); - elements.progressFill.style.width = `${stats.percentComplete}%`; - elements.progressText.textContent = t("progressText", { - percent: stats.percentComplete, + + // Update progress bar (now shows progress to next milestone) + elements.progressFill.style.width = `${stats.progressToNext}%`; + + // Update progress text + elements.progressText.textContent = t("progressTextMilestone", { completed: stats.totalCompleted, - total: stats.totalLessons + next: stats.nextMilestone }); + + // Update milestone indicators + if (elements.milestonesContainer) { + const milestoneEls = elements.milestonesContainer.querySelectorAll(".milestone"); + milestoneEls.forEach((el) => { + const value = parseInt(el.dataset.value, 10); + el.classList.remove("reached", "current", "next", "just-reached"); + + if (stats.milestonesReached.includes(value)) { + el.classList.add("reached"); + // Check if this milestone was just reached + if (value > lastMilestoneReached && value === stats.currentMilestone) { + el.classList.add("just-reached"); + } + } else if (value === stats.nextMilestone) { + el.classList.add("next"); + } + + if (value === stats.currentMilestone) { + el.classList.add("current"); + } + }); + } + + // Update last milestone for celebration detection + if (stats.currentMilestone > lastMilestoneReached) { + lastMilestoneReached = stats.currentMilestone; + } } // ================= USER SETTINGS ================= diff --git a/src/auth.js b/src/auth.js index 3d89887..2268d45 100644 --- a/src/auth.js +++ b/src/auth.js @@ -261,12 +261,18 @@ function setupAuthForms() { }); // OAuth buttons - document.getElementById("google-login")?.addEventListener("click", () => { - authModule?.signInWithGoogle(); + document.getElementById("google-login")?.addEventListener("click", async () => { + const { error } = await authModule?.signInWithGoogle() ?? { error: null }; + if (error) { + showOAuthError(error.message); + } }); - document.getElementById("github-login")?.addEventListener("click", () => { - authModule?.signInWithGitHub(); + document.getElementById("github-login")?.addEventListener("click", async () => { + const { error } = await authModule?.signInWithGitHub() ?? { error: null }; + if (error) { + showOAuthError(error.message); + } }); // Close dialog on backdrop click @@ -364,6 +370,22 @@ async function handleResetSubmit(e) { } } +function showOAuthError(message) { + // Show error in the currently visible form's error element + const loginError = document.getElementById("login-error"); + const signupError = document.getElementById("signup-error"); + + // Use whichever form is visible + const errorEl = !document.getElementById("login-form")?.classList.contains("hidden") + ? loginError + : signupError; + + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove("hidden"); + } +} + function switchForm(formName) { const loginForm = document.getElementById("login-form"); const signupForm = document.getElementById("signup-form"); diff --git a/src/config/lessons.js b/src/config/lessons.js index 8eb7144..56dd0e8 100644 --- a/src/config/lessons.js +++ b/src/config/lessons.js @@ -16,6 +16,10 @@ import htmlElementsEN from "../../lessons/20-html-elements.json"; import htmlFormsBasicEN from "../../lessons/21-html-forms-basic.json"; import htmlFormsValidationEN from "../../lessons/22-html-forms-validation.json"; import htmlDetailsSummaryEN from "../../lessons/23-html-details-summary.json"; +import htmlProgressMeterEN from "../../lessons/24-html-progress-meter.json"; +import htmlDatalistEN from "../../lessons/25-html-datalist.json"; +import htmlDialogEN from "../../lessons/27-html-dialog.json"; +import htmlFieldsetEN from "../../lessons/28-html-forms-fieldset.json"; import htmlFigureEN from "../../lessons/29-html-figure.json"; import htmlTablesEN from "../../lessons/30-html-tables.json"; import htmlSvgEN from "../../lessons/32-html-svg.json"; @@ -35,6 +39,10 @@ import htmlElementsDE from "../../lessons/de/20-html-elements.json"; import htmlFormsBasicDE from "../../lessons/de/21-html-forms-basic.json"; import htmlFormsValidationDE from "../../lessons/de/22-html-forms-validation.json"; import htmlDetailsSummaryDE from "../../lessons/de/23-html-details-summary.json"; +import htmlProgressMeterDE from "../../lessons/de/24-html-progress-meter.json"; +import htmlDatalistDE from "../../lessons/de/25-html-datalist.json"; +import htmlDialogDE from "../../lessons/de/27-html-dialog.json"; +import htmlFieldsetDE from "../../lessons/de/28-html-forms-fieldset.json"; import htmlTablesDE from "../../lessons/de/30-html-tables.json"; import htmlSvgDE from "../../lessons/de/32-html-svg.json"; import flexboxDE from "../../lessons/de/flexbox.json"; @@ -50,6 +58,10 @@ import htmlElementsPL from "../../lessons/pl/20-html-elements.json"; import htmlFormsBasicPL from "../../lessons/pl/21-html-forms-basic.json"; import htmlFormsValidationPL from "../../lessons/pl/22-html-forms-validation.json"; import htmlDetailsSummaryPL from "../../lessons/pl/23-html-details-summary.json"; +import htmlProgressMeterPL from "../../lessons/pl/24-html-progress-meter.json"; +import htmlDatalistPL from "../../lessons/pl/25-html-datalist.json"; +import htmlDialogPL from "../../lessons/pl/27-html-dialog.json"; +import htmlFieldsetPL from "../../lessons/pl/28-html-forms-fieldset.json"; import htmlTablesPL from "../../lessons/pl/30-html-tables.json"; import htmlSvgPL from "../../lessons/pl/32-html-svg.json"; import flexboxPL from "../../lessons/pl/flexbox.json"; @@ -65,6 +77,10 @@ import htmlElementsES from "../../lessons/es/20-html-elements.json"; import htmlFormsBasicES from "../../lessons/es/21-html-forms-basic.json"; import htmlFormsValidationES from "../../lessons/es/22-html-forms-validation.json"; import htmlDetailsSummaryES from "../../lessons/es/23-html-details-summary.json"; +import htmlProgressMeterES from "../../lessons/es/24-html-progress-meter.json"; +import htmlDatalistES from "../../lessons/es/25-html-datalist.json"; +import htmlDialogES from "../../lessons/es/27-html-dialog.json"; +import htmlFieldsetES from "../../lessons/es/28-html-forms-fieldset.json"; import htmlTablesES from "../../lessons/es/30-html-tables.json"; import htmlSvgES from "../../lessons/es/32-html-svg.json"; import flexboxES from "../../lessons/es/flexbox.json"; @@ -80,6 +96,10 @@ import htmlElementsAR from "../../lessons/ar/20-html-elements.json"; import htmlFormsBasicAR from "../../lessons/ar/21-html-forms-basic.json"; import htmlFormsValidationAR from "../../lessons/ar/22-html-forms-validation.json"; import htmlDetailsSummaryAR from "../../lessons/ar/23-html-details-summary.json"; +import htmlProgressMeterAR from "../../lessons/ar/24-html-progress-meter.json"; +import htmlDatalistAR from "../../lessons/ar/25-html-datalist.json"; +import htmlDialogAR from "../../lessons/ar/27-html-dialog.json"; +import htmlFieldsetAR from "../../lessons/ar/28-html-forms-fieldset.json"; import htmlTablesAR from "../../lessons/ar/30-html-tables.json"; import htmlSvgAR from "../../lessons/ar/32-html-svg.json"; import flexboxAR from "../../lessons/ar/flexbox.json"; @@ -95,6 +115,10 @@ import htmlElementsUK from "../../lessons/uk/20-html-elements.json"; import htmlFormsBasicUK from "../../lessons/uk/21-html-forms-basic.json"; import htmlFormsValidationUK from "../../lessons/uk/22-html-forms-validation.json"; import htmlDetailsSummaryUK from "../../lessons/uk/23-html-details-summary.json"; +import htmlProgressMeterUK from "../../lessons/uk/24-html-progress-meter.json"; +import htmlDatalistUK from "../../lessons/uk/25-html-datalist.json"; +import htmlDialogUK from "../../lessons/uk/27-html-dialog.json"; +import htmlFieldsetUK from "../../lessons/uk/28-html-forms-fieldset.json"; import htmlTablesUK from "../../lessons/uk/30-html-tables.json"; import htmlSvgUK from "../../lessons/uk/32-html-svg.json"; import flexboxUK from "../../lessons/uk/flexbox.json"; @@ -121,8 +145,12 @@ const moduleStoreEN = [ htmlSvgEN, // HTML Interactive htmlDetailsSummaryEN, + htmlDialogEN, + htmlProgressMeterEN, htmlFormsBasicEN, htmlFormsValidationEN, + htmlFieldsetEN, + htmlDatalistEN, htmlTablesEN, // Outro goodbyeEN, @@ -151,8 +179,12 @@ const moduleStoreDE = [ htmlSvgDE, // HTML Interactive htmlDetailsSummaryDE, + htmlDialogDE, + htmlProgressMeterDE, htmlFormsBasicDE, htmlFormsValidationDE, + htmlFieldsetDE, + htmlDatalistDE, htmlTablesDE, // Outro goodbyeEN, @@ -181,8 +213,12 @@ const moduleStorePL = [ htmlSvgPL, // HTML Interactive htmlDetailsSummaryPL, + htmlDialogPL, + htmlProgressMeterPL, htmlFormsBasicPL, htmlFormsValidationPL, + htmlFieldsetPL, + htmlDatalistPL, htmlTablesPL, // Outro goodbyeEN, @@ -211,8 +247,12 @@ const moduleStoreES = [ htmlSvgES, // HTML Interactive htmlDetailsSummaryES, + htmlDialogES, + htmlProgressMeterES, htmlFormsBasicES, htmlFormsValidationES, + htmlFieldsetES, + htmlDatalistES, htmlTablesES, // Outro goodbyeEN, @@ -241,8 +281,12 @@ const moduleStoreAR = [ htmlSvgAR, // HTML Interactive htmlDetailsSummaryAR, + htmlDialogAR, + htmlProgressMeterAR, htmlFormsBasicAR, htmlFormsValidationAR, + htmlFieldsetAR, + htmlDatalistAR, htmlTablesAR, // Outro goodbyeEN, @@ -271,8 +315,12 @@ const moduleStoreUK = [ htmlSvgUK, // HTML Interactive htmlDetailsSummaryUK, + htmlDialogUK, + htmlProgressMeterUK, htmlFormsBasicUK, htmlFormsValidationUK, + htmlFieldsetUK, + htmlDatalistUK, htmlTablesUK, // Outro goodbyeEN, diff --git a/src/i18n.js b/src/i18n.js index f9e47fa..5a2b621 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -39,6 +39,7 @@ const translations = { language: "Language", progress: "Progress", progressText: "{percent}% Complete ({completed}/{total})", + progressTextMilestone: "{completed} of {next}", lessons: "Lessons", settings: "Settings", showHints: "Show Hints", @@ -260,6 +261,7 @@ const translations = { language: "Sprache", progress: "Fortschritt", progressText: "{percent}% abgeschlossen ({completed}/{total})", + progressTextMilestone: "{completed} von {next}", lessons: "Lektionen", settings: "Einstellungen", showHints: "Hinweise anzeigen", @@ -481,6 +483,7 @@ const translations = { language: "Język", progress: "Postęp", progressText: "{percent}% ukończone ({completed}/{total})", + progressTextMilestone: "{completed} z {next}", lessons: "Lekcje", settings: "Ustawienia", showHints: "Pokaż podpowiedzi", @@ -701,6 +704,7 @@ const translations = { language: "Idioma", progress: "Progreso", progressText: "{percent}% completado ({completed}/{total})", + progressTextMilestone: "{completed} de {next}", lessons: "Lecciones", settings: "Configuración", showHints: "Mostrar pistas", @@ -923,6 +927,7 @@ const translations = { language: "اللغة", progress: "التقدم", progressText: "{percent}% مكتمل ({completed}/{total})", + progressTextMilestone: "{completed} من {next}", lessons: "الدروس", settings: "الإعدادات", showHints: "إظهار التلميحات", @@ -1140,6 +1145,7 @@ const translations = { language: "Мова", progress: "Прогрес", progressText: "{percent}% завершено ({completed}/{total})", + progressTextMilestone: "{completed} з {next}", lessons: "Уроки", settings: "Налаштування", showHints: "Показувати підказки", diff --git a/src/impl/LessonEngine.js b/src/impl/LessonEngine.js index c0de5ac..795e6b7 100644 --- a/src/impl/LessonEngine.js +++ b/src/impl/LessonEngine.js @@ -472,10 +472,11 @@ export class LessonEngine { } /** - * Get overall progress statistics - * @returns {Object} Progress statistics + * Get overall progress statistics with milestone data + * @returns {Object} Progress statistics including milestone progress */ getProgressStats() { + const MILESTONES = [1, 5, 10, 20, 30, 50, 75, 100]; let totalLessons = 0; let totalCompleted = 0; @@ -490,10 +491,25 @@ export class LessonEngine { } }); + // Calculate milestone progress + const milestonesReached = MILESTONES.filter((m) => totalCompleted >= m); + const currentMilestone = milestonesReached[milestonesReached.length - 1] || 0; + const nextMilestone = MILESTONES.find((m) => m > totalCompleted) || 100; + const progressToNext = + nextMilestone > currentMilestone + ? Math.round(((totalCompleted - currentMilestone) / (nextMilestone - currentMilestone)) * 100) + : 100; + return { totalLessons, totalCompleted, - percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0 + percentComplete: totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0, + // Milestone data + milestones: MILESTONES, + milestonesReached, + currentMilestone, + nextMilestone, + progressToNext }; } diff --git a/src/index.html b/src/index.html index c0a5c2f..fe40d48 100644 --- a/src/index.html +++ b/src/index.html @@ -468,11 +468,21 @@