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
This commit is contained in:
2026-01-16 12:37:22 +01:00
parent ea57ce6d28
commit 68407fe12b
12 changed files with 1520 additions and 26 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@
node_modules
dist
coverage
.env
.env.local
# Claude Code local settings (user-specific)
.claude/settings.local.json

129
package-lock.json generated
View File

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

View File

@@ -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"
}

View File

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

419
src/auth.js Normal file
View File

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

View File

@@ -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: "Видалити акаунт"
}
};

View File

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

View File

@@ -77,6 +77,8 @@
<a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a>
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
</nav>
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
<span id="user-email-header" class="user-email hidden"></span>
<button id="help-btn" class="help-toggle" data-i18n-aria-label="help" aria-label="Help">?</button>
</div>
</header>
@@ -139,7 +141,7 @@
</svg>
<h3 data-i18n="landingBenefit4Title">Free & Open Source</h3>
<p data-i18n="landingBenefit4Text">
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.
</p>
</article>
</div>
@@ -172,13 +174,6 @@
<section class="coming-soon">
<h2 data-i18n="landingComingSoonTitle">Coming Soon</h2>
<div class="coming-soon-grid">
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 0 1 9-9"/></svg>
</span>
<h3 data-i18n="comingSoonSyncTitle">Cloud Sync</h3>
<p data-i18n="comingSoonSyncText">Sync your progress across all devices. Start on desktop, continue on tablet.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>
@@ -200,6 +195,13 @@
<h3 data-i18n="comingSoonFrameworksTitle">Frameworks</h3>
<p data-i18n="comingSoonFrameworksText">React, Vue, and Svelte basics. Build real components step by step.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
</span>
<h3 data-i18n="comingSoonChallengesTitle">Code Challenges</h3>
<p data-i18n="comingSoonChallengesText">Test your skills with timed puzzles. Compete on leaderboards and earn ranks.</p>
</article>
</div>
<div class="newsletter-signup">
<p data-i18n="newsletterText">Want to know when new features launch?</p>
@@ -254,6 +256,11 @@
</div>
<div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
@@ -306,6 +313,11 @@
</div>
<div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
@@ -354,6 +366,11 @@
</div>
<div class="footer-bottom">
<p>&copy; 2025 <a href="https://librete.ch">LibreTECH</a>. <span data-i18n="footerLicense">Open source under MIT License.</span></p>
<p class="footer-legal">
<button type="button" class="btn-text privacy-link" data-i18n="footerPrivacy">Privacy Policy</button>
<span class="footer-separator">·</span>
<button type="button" class="btn-text imprint-link" data-i18n="footerImprint">Imprint</button>
</p>
</div>
</footer>
</div>
@@ -462,6 +479,17 @@
<nav class="sidebar-section" aria-label="Lesson navigation">
<h4 id="lessons-heading" data-i18n="lessons">Lessons</h4>
<div class="module-list" id="module-list" role="tree" aria-labelledby="lessons-heading"></div>
<div class="sidebar-auth-box">
<h4 data-i18n="authAccount">Account</h4>
<button id="auth-trigger-sidebar" class="btn btn-outline btn-full" data-i18n="authLogin">Log In</button>
<div id="user-menu-sidebar" class="user-menu-sidebar hidden">
<span id="user-email-sidebar" class="user-email"></span>
<button id="logout-btn-sidebar" class="btn btn-outline btn-full" data-i18n="authLogout">Log Out</button>
<button id="delete-account-btn" class="btn btn-text btn-danger btn-full" data-i18n="authDeleteAccount">Delete Account</button>
</div>
<p class="sidebar-auth-hint" data-i18n="authSyncHint">Log in to sync progress across devices</p>
</div>
</nav>
<div class="sidebar-section">
@@ -616,6 +644,22 @@
</div>
</dialog>
<!-- Delete Account Confirmation Dialog -->
<dialog id="delete-account-dialog" class="dialog">
<div class="dialog-header">
<h3 data-i18n="authDeleteDialogTitle">Delete Account</h3>
<button id="delete-dialog-close" class="dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content">
<p data-i18n="authDeleteDialogText">Are you sure you want to delete your account? All your cloud progress will be permanently deleted. This cannot be undone.</p>
<p id="delete-account-error" class="auth-error hidden"></p>
<div class="dialog-actions">
<button id="cancel-delete" class="btn" data-i18n="cancel">Cancel</button>
<button id="confirm-delete" class="btn btn-danger" data-i18n="authDeleteConfirm">Delete Account</button>
</div>
</div>
</dialog>
<!-- Share Dialog -->
<dialog id="share-dialog" class="dialog">
<div class="dialog-header">
@@ -631,6 +675,136 @@
<p id="copy-feedback" class="copy-feedback" data-i18n="urlCopied" hidden>URL copied to clipboard!</p>
</div>
</dialog>
<!-- Privacy Policy Dialog -->
<dialog id="privacy-dialog" class="dialog legal-dialog">
<div class="dialog-header">
<h3 data-i18n="privacyTitle">Privacy Policy</h3>
<button class="dialog-close privacy-dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content legal-content">
<p data-i18n="privacyIntro">CODE CRISPIES respects your privacy. This policy explains what data we collect and how we use it.</p>
<h4 data-i18n="privacyLocalTitle">Local Storage</h4>
<p data-i18n="privacyLocalText">Your learning progress, code, and settings are stored locally in your browser. This data never leaves your device unless you create an account.</p>
<h4 data-i18n="privacyAccountTitle">Account Data (Optional)</h4>
<p data-i18n="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.</p>
<h4 data-i18n="privacyNewsletterTitle">Newsletter (Optional)</h4>
<p data-i18n="privacyNewsletterText">If you subscribe to our newsletter, we store your email address to send updates about new features. You can unsubscribe anytime.</p>
<h4 data-i18n="privacyNoTrackingTitle">No Tracking</h4>
<p data-i18n="privacyNoTrackingText">We do not use cookies for tracking, analytics, or advertising. We do not share your data with third parties.</p>
<h4 data-i18n="privacyRightsTitle">Your Rights (GDPR)</h4>
<p data-i18n="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</p>
<p class="legal-updated" data-i18n="privacyUpdated">Last updated: January 2025</p>
</div>
</dialog>
<!-- Imprint Dialog -->
<dialog id="imprint-dialog" class="dialog legal-dialog">
<div class="dialog-header">
<h3 data-i18n="imprintTitle">Imprint</h3>
<button class="dialog-close imprint-dialog-close" aria-label="Close">&times;</button>
</div>
<div class="dialog-content legal-content">
<h4 data-i18n="imprintResponsibleTitle">Responsible for content</h4>
<p>
Michael Czechowski<br>
Schnellweg 3<br>
70199 Stuttgart<br>
Germany
</p>
<h4 data-i18n="imprintContactTitle">Contact</h4>
<p>
Email: mail@codecrispi.es<br>
Website: <a href="https://librete.ch" target="_blank">librete.ch</a>
</p>
<h4 data-i18n="imprintDisclaimerTitle">Disclaimer</h4>
<p data-i18n="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.</p>
</div>
</dialog>
<!-- Auth Dialog -->
<dialog id="auth-dialog" class="dialog auth-dialog">
<div class="dialog-header">
<h2 id="auth-dialog-title" data-i18n="authLogin">Log In</h2>
<button class="dialog-close close-dialog" aria-label="Close">&times;</button>
</div>
<div class="dialog-content">
<!-- Login Form -->
<form id="login-form" class="auth-form">
<div class="form-field">
<label for="login-email" data-i18n="authEmail">Email</label>
<input type="email" id="login-email" required autocomplete="email">
</div>
<div class="form-field">
<label for="login-password" data-i18n="authPassword">Password</label>
<input type="password" id="login-password" required minlength="6" autocomplete="current-password">
</div>
<p id="login-error" class="auth-error hidden"></p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authLogin">Log In</button>
</form>
<!-- Signup Form (hidden by default) -->
<form id="signup-form" class="auth-form hidden">
<div class="form-field">
<label for="signup-email" data-i18n="authEmail">Email</label>
<input type="email" id="signup-email" required autocomplete="email">
</div>
<div class="form-field">
<label for="signup-password" data-i18n="authPassword">Password</label>
<input type="password" id="signup-password" required minlength="6" autocomplete="new-password">
</div>
<div class="form-field">
<label for="signup-confirm" data-i18n="authConfirmPassword">Confirm Password</label>
<input type="password" id="signup-confirm" required minlength="6" autocomplete="new-password">
</div>
<p id="signup-error" class="auth-error hidden"></p>
<p id="signup-success" class="auth-success hidden" data-i18n="authSignupSuccess">Account created! Check your email to confirm.</p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSignUp">Sign Up</button>
</form>
<!-- Password Reset Form (hidden by default) -->
<form id="reset-form" class="auth-form hidden">
<p class="auth-instructions" data-i18n="authResetInstructions">Enter your email to receive a password reset link.</p>
<div class="form-field">
<label for="reset-email" data-i18n="authEmail">Email</label>
<input type="email" id="reset-email" required autocomplete="email">
</div>
<p id="reset-error" class="auth-error hidden"></p>
<p id="reset-success" class="auth-success hidden" data-i18n="authResetSent">Check your email for the reset link.</p>
<button type="submit" class="btn btn-primary btn-full" data-i18n="authSendReset">Send Reset Link</button>
</form>
<!-- Social Login Buttons -->
<div class="auth-social">
<div class="auth-divider"><span data-i18n="authOrContinueWith">or continue with</span></div>
<div class="auth-social-buttons">
<button type="button" id="google-login" class="btn btn-social">
<svg class="social-icon" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Google
</button>
<button type="button" id="github-login" class="btn btn-social">
<svg class="social-icon" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" fill="currentColor"/></svg>
GitHub
</button>
</div>
</div>
<!-- Form switcher links -->
<div class="auth-links">
<button type="button" id="show-signup" class="btn-text" data-i18n="authNoAccount">Don't have an account? Sign up</button>
<button type="button" id="show-login" class="btn-text hidden" data-i18n="authHaveAccount">Already have an account? Log in</button>
<button type="button" id="show-reset" class="btn-text" data-i18n="authForgotPassword">Forgot password?</button>
</div>
</div>
</dialog>
</div>
<script type="module" src="app.js"></script>

View File

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

98
src/supabase.js Normal file
View File

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

53
supabase-setup.sql Normal file
View File

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

View File

@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
export default defineConfig((env) => ({
base: "/",
root: "./src",
envDir: "..",
publicDir: "../public",
build: {
outDir: "../dist",