- Add computeLessonDifficulty function to determine lesson difficulty based on selector complexity (easy/medium/hard) - Display difficulty badge with bar indicator in lesson title row - Add mobile navigation links (CSS, HTML, Tailwind) to sidebar - Add mobile auth trigger button in sidebar - Redesign settings section with card layout and native toggles - Add difficulty translations for all 6 languages - Fix module pill overflow on narrow screens
527 lines
16 KiB
JavaScript
527 lines
16 KiB
JavaScript
import { t, applyTranslations } from "./i18n.js";
|
|
|
|
// Analytics tracking helper
|
|
function track(eventName, eventData = {}) {
|
|
if (typeof umami !== "undefined" && umami.track) {
|
|
umami.track(eventName, eventData);
|
|
}
|
|
}
|
|
|
|
let currentUser = null;
|
|
let oauthHandled = false;
|
|
let lessonEngineRef = null;
|
|
let authModule = null;
|
|
let progressModule = null;
|
|
let supabaseAvailable = false;
|
|
|
|
/**
|
|
* Check for OAuth callback tokens in URL hash BEFORE router runs.
|
|
* Call this before initializing the router.
|
|
* @returns {Promise<boolean>} true if OAuth callback was detected and handled
|
|
*/
|
|
export async function handleOAuthCallback() {
|
|
const hash = window.location.hash;
|
|
|
|
// Check if hash contains OAuth tokens (access_token, error, etc.)
|
|
if (!hash.includes("access_token") && !hash.includes("error_description") && !hash.includes("refresh_token")) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const supabaseModule = await import("./supabase.js");
|
|
if (!supabaseModule.isConfigured) {
|
|
return false;
|
|
}
|
|
|
|
// Parse tokens from hash
|
|
const params = new URLSearchParams(hash.substring(1));
|
|
const accessToken = params.get("access_token");
|
|
const refreshToken = params.get("refresh_token");
|
|
|
|
if (accessToken && refreshToken) {
|
|
// Explicitly set the session with tokens from URL
|
|
const { data, error } = await supabaseModule.auth.setSession({
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken
|
|
});
|
|
|
|
if (!error && data?.session) {
|
|
oauthHandled = true;
|
|
const provider = data.session.user?.app_metadata?.provider || "oauth";
|
|
track("auth_login", { method: provider });
|
|
}
|
|
}
|
|
|
|
// Restore the original route (saved before OAuth redirect)
|
|
const savedRoute = localStorage.getItem("codeCrispies.oauthReturnRoute");
|
|
if (savedRoute) {
|
|
localStorage.removeItem("codeCrispies.oauthReturnRoute");
|
|
window.history.replaceState(null, "", window.location.pathname + savedRoute);
|
|
} else {
|
|
// No saved route - just clear the hash
|
|
window.history.replaceState(null, "", window.location.pathname);
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
console.error("[Auth] OAuth callback handling failed:", e.message);
|
|
return 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;
|
|
}
|
|
|
|
// Listen for auth changes
|
|
authModule.onAuthStateChange((event, session) => {
|
|
if (event === "SIGNED_IN" && session?.user) {
|
|
handleLogin(session.user);
|
|
} else if (event === "SIGNED_OUT") {
|
|
handleLogout();
|
|
}
|
|
});
|
|
|
|
// Check initial session
|
|
try {
|
|
const { data } = await authModule.getSession();
|
|
if (data?.session?.user) handleLogin(data.session.user);
|
|
} catch (e) {
|
|
// Session check failed - continue without auth
|
|
}
|
|
|
|
// 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 authTriggerMobile = document.getElementById("auth-trigger-mobile");
|
|
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");
|
|
authTriggerMobile?.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");
|
|
authTriggerMobile?.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 (header, sidebar, and mobile)
|
|
document
|
|
.getElementById("auth-trigger-header")
|
|
?.addEventListener("click", () => {
|
|
authDialog?.showModal();
|
|
});
|
|
document
|
|
.getElementById("auth-trigger-sidebar")
|
|
?.addEventListener("click", () => {
|
|
authDialog?.showModal();
|
|
});
|
|
document
|
|
.getElementById("auth-trigger-mobile")
|
|
?.addEventListener("click", () => {
|
|
authDialog?.showModal();
|
|
});
|
|
|
|
// Logout button (sidebar only)
|
|
document
|
|
.getElementById("logout-btn-sidebar")
|
|
?.addEventListener("click", async () => {
|
|
await authModule?.signOut();
|
|
track("auth_logout");
|
|
});
|
|
|
|
// 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();
|
|
track("auth_delete_account");
|
|
// Sign out and clear local state
|
|
await authModule.signOut();
|
|
}
|
|
});
|
|
|
|
// OAuth buttons - save current route before redirect
|
|
document.getElementById("google-login")?.addEventListener("click", async () => {
|
|
// Save current route to restore after OAuth
|
|
const currentHash = window.location.hash;
|
|
if (currentHash && !currentHash.includes("access_token")) {
|
|
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
|
|
}
|
|
const { error } = await authModule?.signInWithGoogle() ?? { error: null };
|
|
if (error) {
|
|
showOAuthError(error.message);
|
|
}
|
|
});
|
|
|
|
document.getElementById("github-login")?.addEventListener("click", async () => {
|
|
// Save current route to restore after OAuth
|
|
const currentHash = window.location.hash;
|
|
if (currentHash && !currentHash.includes("access_token")) {
|
|
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
|
|
}
|
|
const { error } = await authModule?.signInWithGitHub() ?? { error: null };
|
|
if (error) {
|
|
showOAuthError(error.message);
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
track("auth_login", { method: "email" });
|
|
}
|
|
}
|
|
|
|
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");
|
|
});
|
|
track("auth_signup", { method: "email" });
|
|
}
|
|
}
|
|
|
|
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 showOAuthError(message) {
|
|
// Show error in the currently visible form's error element
|
|
const loginError = document.getElementById("login-error");
|
|
const signupError = document.getElementById("signup-error");
|
|
|
|
// Use whichever form is visible
|
|
const errorEl = !document.getElementById("login-form")?.classList.contains("hidden")
|
|
? loginError
|
|
: signupError;
|
|
|
|
if (errorEl) {
|
|
errorEl.textContent = message;
|
|
errorEl.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function switchForm(formName) {
|
|
const loginForm = document.getElementById("login-form");
|
|
const signupForm = document.getElementById("signup-form");
|
|
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();
|
|
}
|