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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
129
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
41
src/app.js
41
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");
|
||||
|
||||
419
src/auth.js
Normal file
419
src/auth.js
Normal 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();
|
||||
}
|
||||
304
src/i18n.js
304
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: "Видалити акаунт"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
190
src/index.html
190
src/index.html
@@ -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>© 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>© 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>© 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">×</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">×</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">×</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">×</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>
|
||||
|
||||
274
src/main.css
274
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;
|
||||
|
||||
98
src/supabase.js
Normal file
98
src/supabase.js
Normal 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
53
supabase-setup.sql
Normal 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;
|
||||
$$;
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
|
||||
export default defineConfig((env) => ({
|
||||
base: "/",
|
||||
root: "./src",
|
||||
envDir: "..",
|
||||
publicDir: "../public",
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
|
||||
Reference in New Issue
Block a user