From 3c25e6b069ef4a4278d6658b99f5321f2066308b Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Tue, 30 Jun 2026 19:04:49 +0530 Subject: [PATCH 01/20] feat: add auth utility with SQLite user and session store --- src/utils/auth.py | 163 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/utils/auth.py diff --git a/src/utils/auth.py b/src/utils/auth.py new file mode 100644 index 00000000..566e46d9 --- /dev/null +++ b/src/utils/auth.py @@ -0,0 +1,163 @@ +# utils/auth.py +# DB-backed auth for DevPath using SQLite. +# +# Two tables: +# users — username, password hash, salt, path_id +# sessions — token, username (survives server restarts) +# +# Public surface (unchanged): +# register_user(username, password) -> token (str) +# login_user(username, password) -> token (str) +# get_user_from_token(token) -> username (str) or None +# get_user_path_id(username) -> path_id (str) or None +# logout_user(token) -> None +# +# Errors: +# AuthError — username taken, bad credentials, etc. + +import hashlib +import secrets +import sqlite3 +import os + +from config import Config + +class AuthError(Exception): + pass + +# DB file sits next to src/ so it's not inside the package +_DB_PATH = os.getenv( + "DEVPATH_DB", + os.path.join(os.path.dirname(__file__), "..", "..", "devpath.db") +) + +def _connect(): + """Open a new SQLite connection with row access by column name.""" + conn = sqlite3.connect(_DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def _init_db(): + """Create the users and sessions tables if they don't already exist.""" + with _connect() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + pw_hash TEXT NOT NULL, + salt TEXT NOT NULL, + path_id TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL + ) + """) + conn.commit() + +# init on import +_init_db() + +def _hash_password(password, salt): + """Hash a password with the given salt using PBKDF2-HMAC-SHA256.""" + return hashlib.pbkdf2_hmac( + "sha256", password.encode(), salt.encode(), 100_000 + ).hex() + +def register_user(username, password): + """Create a new account and return a fresh session token. + + Validates username/password length, hashes the password with a random + salt, assigns the user a unique learning-path ID, and opens a session. + Raises AuthError if validation fails or the username is already taken. + """ + username = (username or "").strip().lower() + password = (password or "").strip() + + if not username or len(username) < 2: + raise AuthError("Username must be at least 2 characters.") + if not password or len(password) < 4: + raise AuthError("Password must be at least 4 characters.") + + salt = secrets.token_hex(16) + pw_hash = _hash_password(password, salt) + path_id = "user-" + secrets.token_urlsafe(16) + token = secrets.token_urlsafe(32) + + try: + with _connect() as conn: + conn.execute( + "INSERT INTO users (username, pw_hash, salt, path_id) " + "VALUES (?, ?, ?, ?)", + (username, pw_hash, salt, path_id) + ) + conn.execute( + "INSERT INTO sessions (token, username) VALUES (?, ?)", + (token, username) + ) + conn.commit() + except sqlite3.IntegrityError: + raise AuthError("Username already taken.") + + return token + +def login_user(username, password): + """Validate credentials and return a fresh session token. + + Raises AuthError with a generic "invalid username or password" message + on either a missing user or a wrong password, so failed attempts can't + be used to enumerate which usernames exist. + """ + username = (username or "").strip().lower() + password = (password or "").strip() + + with _connect() as conn: + row = conn.execute( + "SELECT pw_hash, salt FROM users WHERE username = ?", (username,) + ).fetchone() + + if not row: + raise AuthError("Invalid username or password.") + + pw_hash = _hash_password(password, row["salt"]) + if not secrets.compare_digest(pw_hash, row["pw_hash"]): + raise AuthError("Invalid username or password.") + + token = secrets.token_urlsafe(32) + with _connect() as conn: + conn.execute( + "INSERT INTO sessions (token, username) VALUES (?, ?)", (token, username) + ) + conn.commit() + + return token + +def get_user_from_token(token): + """Return the username tied to a session token, or None if invalid.""" + if not token: + return None + with _connect() as conn: + row = conn.execute( + "SELECT username FROM sessions WHERE token = ?", (token,) + ).fetchone() + return row["username"] if row else None + +def get_user_path_id(username): + """Return the server-side learning-path ID for a user, or None.""" + if not username: + return None + with _connect() as conn: + row = conn.execute( + "SELECT path_id FROM users WHERE username = ?", + ((username or "").strip().lower(),) + ).fetchone() + return row["path_id"] if row else None + +def logout_user(token): + """Delete the session for a token, if it exists. No-op on empty token.""" + if not token: + return + with _connect() as conn: + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() From feb2c64150ddf5ce58d98d05efcfa559ab3522a3 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Tue, 30 Jun 2026 19:06:19 +0530 Subject: [PATCH 02/20] feat: add session-based auth API endpoints --- src/routes/main_routes.py | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py index dec859bd..172d976c 100644 --- a/src/routes/main_routes.py +++ b/src/routes/main_routes.py @@ -422,3 +422,94 @@ def update_path(path_id): return jsonify({"error": "Forbidden: invalid token for this path."}), 403 return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200 + + +# --------------------------------------------------------------------------- +# Auth API +# +# Simple register / login / logout / me endpoints. +# On login the client gets back a session token + the user's learning-path ID +# so it can immediately sync progress via the /api/learning-path/* endpoints. +# --------------------------------------------------------------------------- + +from utils.auth import ( + register_user, login_user, get_user_from_token, + get_user_path_id, logout_user, AuthError +) + + +@main.route("/api/auth/register", methods=["POST"]) +def auth_register(): + """Create a new account from a JSON body of {username, password}. + + Response 201: {"token", "username", "path_id"} + Response 400: validation error or duplicate username + """ + payload = request.get_json(silent=True) + if not payload: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + username = (payload.get("username") or "").strip() + password = (payload.get("password") or "").strip() + + try: + token = register_user(username, password) + except AuthError as e: + return jsonify({"error": str(e)}), 400 + + path_id = get_user_path_id(username.lower()) + return jsonify( + {"token": token, "username": username.lower(), "path_id": path_id} + ), 201 + + +@main.route("/api/auth/login", methods=["POST"]) +def auth_login(): + """Validate credentials from a JSON body of {username, password}. + + Response 200: {"token", "username", "path_id"} + Response 401: invalid username or password + """ + payload = request.get_json(silent=True) + if not payload: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + username = (payload.get("username") or "").strip() + password = (payload.get("password") or "").strip() + + try: + token = login_user(username, password) + except AuthError as e: + return jsonify({"error": str(e)}), 401 + + path_id = get_user_path_id(username.lower()) + return jsonify( + {"token": token, "username": username.lower(), "path_id": path_id} + ), 200 + + +@main.route("/api/auth/logout", methods=["POST"]) +def auth_logout(): + """Invalidate the session token sent in the X-Auth-Token header. + + Response 200 always, even if the token is missing or already invalid. + """ + token = request.headers.get("X-Auth-Token", "").strip() + if token: + logout_user(token) + return jsonify({"message": "Logged out."}), 200 + + +@main.route("/api/auth/me", methods=["GET"]) +def auth_me(): + """Return the signed-in username and path_id for the given session token. + + Response 200: {"username", "path_id"} + Response 401: missing or invalid X-Auth-Token header + """ + token = request.headers.get("X-Auth-Token", "").strip() + username = get_user_from_token(token) + if not username: + return jsonify({"error": "Not authenticated."}), 401 + path_id = get_user_path_id(username) + return jsonify({"username": username, "path_id": path_id}), 200 From 5e7e42b8f8e1d79323b9fba4f87e1c4817ba0511 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Tue, 30 Jun 2026 19:07:16 +0530 Subject: [PATCH 03/20] feat: expose window.clearSkills for form reset on logout --- src/static/script.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/static/script.js b/src/static/script.js index 9aeb0bd8..10cb61f9 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -478,6 +478,13 @@ updateProfileWidgets(); if (skillsInput) skillsInput.focus(); }; + window.clearSkills = function clearSkills() { + selectedSkills = []; + renderSelectedChips(); + syncSkillsHiddenInput(); + updateQuickPickState(); + }; + function removeSkill(skill) { selectedSkills = selectedSkills.filter(function (item) { return normalize(item) !== normalize(skill); }); renderSelectedChips(); From b3841af9376fb073c5bf9853f2a88486645e89c9 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Tue, 30 Jun 2026 19:08:35 +0530 Subject: [PATCH 04/20] fix: add missing desktop nav-links and wire up auth UI --- src/templates/index.html | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/templates/index.html b/src/templates/index.html index 9ae9e04f..31888e16 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -376,6 +376,15 @@ ', + + '
', + '', + '', + '
', + + '
', + + '
', + '', + '', + '', + '
', + '' + ].join(""); + + document.body.appendChild(el); + wireModalEvents(el); + } + + // Attach all event handlers for the modal (tabs, submit, close, enter-key). + function wireModalEvents(overlay) { + var closeBtn = document.getElementById("auth-modal-close"); + var tabLogin = document.getElementById("auth-tab-login"); + var tabReg = document.getElementById("auth-tab-register"); + var submitBtn = document.getElementById("auth-submit-btn"); + var errorEl = document.getElementById("auth-error"); + var titleEl = document.getElementById("auth-modal-title"); + var mode = "login"; + + // Switch the modal between login and register mode. + function setMode(m) { + mode = m; + titleEl.textContent = m === "login" ? "Sign In" : "Create Account"; + submitBtn.textContent = m === "login" ? "Sign In" : "Sign Up"; + document.getElementById("auth-password").setAttribute("autocomplete", + m === "login" ? "current-password" : "new-password"); + tabLogin.classList.toggle("auth-tab--active", m === "login"); + tabReg.classList.toggle("auth-tab--active", m === "register"); + showError(""); + } + + // Show or clear the inline error banner. + function showError(msg) { + errorEl.textContent = msg; + errorEl.style.display = msg ? "block" : "none"; + } + + tabLogin.addEventListener("click", function() { setMode("login"); }); + tabReg.addEventListener("click", function() { setMode("register"); }); + closeBtn.addEventListener("click", closeModal); + overlay.addEventListener("click", function(e) { if (e.target === overlay) closeModal(); }); + + // Submit the form to /api/auth/login or /api/auth/register depending on mode. + submitBtn.addEventListener("click", function() { + var username = document.getElementById("auth-username").value.trim(); + var password = document.getElementById("auth-password").value.trim(); + if (!username || !password) { showError("Fill in both fields."); return; } + + submitBtn.disabled = true; + submitBtn.textContent = "..."; + + fetch(mode === "login" ? "/api/auth/login" : "/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password }) + }).then(function(res) { + return res.json().then(function(data) { return { ok: res.ok, data: data }; }); + }).then(function(result) { + submitBtn.disabled = false; + submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; + if (!result.ok) { + var msg = result.data.error || "Something went wrong."; + if (mode === "login" && msg.toLowerCase().indexOf("invalid") !== -1) { + msg = "Invalid username or password. No account yet? Switch to Sign Up."; + } + showError(msg); + return; + } + lsSet(AUTH_TOKEN_KEY, result.data.token); + lsSet(AUTH_USER_KEY, result.data.username); + lsSet(AUTH_PATH_KEY, result.data.path_id); + closeModal(); + updateNavUI(); + updateSubtitle(); + showToast(mode === "login" ? "Successfully signed in" : "Successfully signed up"); + pullProgressFromServer(applyServerProgress); + }).catch(function() { + submitBtn.disabled = false; + submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; + showError("Network error. Try again."); + }); + }); + + ["auth-username", "auth-password"].forEach(function(id) { + document.getElementById(id).addEventListener("keydown", function(e) { + if (e.key === "Enter") submitBtn.click(); + }); + }); + } + + // Show the sign in / sign up modal. + function openModal() { + var o = document.getElementById("auth-modal-overlay"); + if (o) o.style.display = "flex"; + } + // Hide the sign in / sign up modal. + function closeModal() { + var o = document.getElementById("auth-modal-overlay"); + if (o) o.style.display = "none"; + } + + // ---- dashboard ---- + + // Build the slide-in dashboard panel once and append it to the document. + // Styling comes entirely from the .auth-dashboard-* classes in style.css. + function injectDashboard() { + var el = document.createElement("div"); + el.id = "auth-dashboard"; + el.className = "auth-dashboard"; + + el.innerHTML = [ + '
', + '
', + '
Dashboard
', + '
', + '
', + '', + '
', + + '
', + + '
', + '
', + 'Points', + '0', + '
', + '
', + '
', + + '
', + '
0
Searches
', + '
0
Viewed
', + '
0
Code Opens
', + '
0
Completed
', + '
', + + '
', + '', + '
', + '
', + + '
', + '', + '
', + '
', + + '', + '', + + '
', + + '' + ].join(""); + + document.body.appendChild(el); + document.getElementById("dash-close").addEventListener("click", closeDashboard); + document.getElementById("dash-logout").addEventListener("click", function() { + closeDashboard(); + doLogout(); + }); + } + + // Slide the dashboard panel into view and refresh its contents. + function openDashboard() { + var dash = document.getElementById("auth-dashboard"); + if (!dash) return; + refreshDashboard(); + dash.classList.add("auth-dashboard--open"); + } + + // Slide the dashboard panel out of view. + function closeDashboard() { + var dash = document.getElementById("auth-dashboard"); + if (dash) dash.classList.remove("auth-dashboard--open"); + } + + // Exposed globally so the inline onclick on dashboard quick-links can call it. + window.closeDashboard = closeDashboard; + + // Return the CSS class for a level tag based on its value. + function levelTagClass(level) { + if (level === "Beginner") return "auth-tag auth-tag--level-beginner"; + if (level === "Intermediate") return "auth-tag auth-tag--level-intermediate"; + if (level === "Advanced") return "auth-tag auth-tag--level-advanced"; + return "auth-tag"; + } + + // Repaint every widget in the dashboard from the current window.progress + // and DevPathBookmarks state. Called each time the dashboard is opened. + function refreshDashboard() { + var p = window.progress; + if (!p) return; + + var u = document.getElementById("dash-username"); if (u) u.textContent = getUser() || ""; + var pt = document.getElementById("dash-points"); if (pt) pt.textContent = p.points || 0; + var m = document.getElementById("dash-meter"); + if (m) { + var pct = Math.min(100, Math.round(((p.points||0) / (window.PROGRESS_MAX_POINTS||500)) * 100)); + m.style.width = pct + "%"; + } + var s = document.getElementById("dash-stat-searches"); if (s) s.textContent = p.searches || 0; + var v = document.getElementById("dash-stat-viewed"); if (v) v.textContent = p.projectViews || 0; + var co = document.getElementById("dash-stat-codeopens"); if (co) co.textContent = p.codeOpens || 0; + var cp = document.getElementById("dash-stat-completed"); if (cp) cp.textContent = p.completions || 0; + + renderDashboardBadges(p.badges || {}); + renderDashboardSaved(); + } + + // Render the badge unlock list inside the dashboard. + function renderDashboardBadges(badges) { + var badgesEl = document.getElementById("dash-badges"); + if (!badgesEl) return; + var defs = [ + ["first_search","First Search"],["project_explorer","Project Explorer"], + ["code_starter","Code Starter"],["completionist","Completionist"],["roadmap_runner","Roadmap Runner"] + ]; + badgesEl.innerHTML = defs.map(function(b) { + var unlocked = !!badges[b[0]]; + return '
' + + '' + (unlocked ? "✅" : "🔒") + '' + + '' + b[1] + '' + + '
'; + }).join(""); + } + + // Render the saved-projects list inside the dashboard as mini project cards + // matching the colours and layout of the main .project-card component. + function renderDashboardSaved() { + var savedEl = document.getElementById("dash-saved"); + if (!savedEl) return; + var saved = window.DevPathBookmarks ? window.DevPathBookmarks.getSaved() : []; + + if (!saved.length) { + savedEl.innerHTML = 'No saved projects yet.'; + return; + } + + savedEl.innerHTML = saved.slice(0, 5).map(function(proj) { + var tags = (Array.isArray(proj.skills) ? proj.skills.slice(0, 3) : []) + .map(function(skill) { return '' + skill + ''; }) + .join(""); + if (proj.level) tags += '' + proj.level + ''; + if (proj.time) tags += '' + proj.time + ''; + + return '' + + '
' + + '' + proj.title + '' + + (tags ? '
' + tags + '
' : '') + + '
' + + '
'; + }).join(""); + } + + // ---- nav UI ---- + + // Show/hide the Sign In button and Dashboard button in the navbar + // based on current sign-in state. + function updateNavUI() { + var signInBtn = document.getElementById("auth-nav-btn"); + var dashBtn = document.getElementById("auth-logout-btn"); + if (!signInBtn) return; + + var loggedIn = isLoggedIn(); + signInBtn.classList.toggle("auth-hidden", loggedIn); + if (dashBtn) dashBtn.classList.toggle("auth-hidden", !loggedIn); + } + + // Update the progress section subtitle to reflect sign-in state. + function updateSubtitle() { + var sub = document.getElementById("progress-section-sub"); + if (!sub) return; + sub.textContent = isLoggedIn() + ? "Signed in as " + getUser() + " — progress syncs to the server." + : "Sign in to track and sync your progress across devices."; + } + + // ---- init ---- + + document.addEventListener("DOMContentLoaded", function() { + injectModal(); + injectDashboard(); + injectToast(); + updateNavUI(); + updateSubtitle(); + + var signInBtn = document.getElementById("auth-nav-btn"); + if (signInBtn) signInBtn.onclick = openModal; + + var dashBtn = document.getElementById("auth-logout-btn"); + if (dashBtn) dashBtn.onclick = openDashboard; + }); + +})(); From 0691e434188d4d3dd49a12ed33b3adfc8b3f07b4 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Thu, 2 Jul 2026 15:54:20 +0530 Subject: [PATCH 12/20] feat: add mobile buttons for authentication --- src/templates/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/templates/index.html b/src/templates/index.html index 31888e16..3a3154b6 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -456,6 +456,8 @@ Dark Mode + + From 738785ffe051a63a1c1983483a2d9c02e3ec2aea Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Thu, 2 Jul 2026 15:56:16 +0530 Subject: [PATCH 13/20] fix: update functions for mobile authentication nav buttons --- src/static/auth.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/static/auth.js b/src/static/auth.js index 87f46f38..a704311a 100644 --- a/src/static/auth.js +++ b/src/static/auth.js @@ -492,18 +492,22 @@ }).join(""); } - // ---- nav UI ---- + // ---- nav UI ---- - // Show/hide the Sign In button and Dashboard button in the navbar - // based on current sign-in state. + // Show/hide the Sign In button and Dashboard button in both the desktop + // navbar and the mobile menu based on current sign-in state. function updateNavUI() { - var signInBtn = document.getElementById("auth-nav-btn"); - var dashBtn = document.getElementById("auth-logout-btn"); + var signInBtn = document.getElementById("auth-nav-btn"); + var dashBtn = document.getElementById("auth-logout-btn"); + var signInBtnMobile = document.getElementById("auth-nav-btn-mobile"); + var dashBtnMobile = document.getElementById("auth-logout-btn-mobile"); if (!signInBtn) return; var loggedIn = isLoggedIn(); signInBtn.classList.toggle("auth-hidden", loggedIn); - if (dashBtn) dashBtn.classList.toggle("auth-hidden", !loggedIn); + if (dashBtn) dashBtn.classList.toggle("auth-hidden", !loggedIn); + if (signInBtnMobile) signInBtnMobile.classList.toggle("auth-hidden", loggedIn); + if (dashBtnMobile) dashBtnMobile.classList.toggle("auth-hidden", !loggedIn); } // Update the progress section subtitle to reflect sign-in state. @@ -524,11 +528,19 @@ updateNavUI(); updateSubtitle(); + // Desktop buttons var signInBtn = document.getElementById("auth-nav-btn"); if (signInBtn) signInBtn.onclick = openModal; var dashBtn = document.getElementById("auth-logout-btn"); if (dashBtn) dashBtn.onclick = openDashboard; + + // Mobile menu buttons — same actions as desktop + var signInBtnMobile = document.getElementById("auth-nav-btn-mobile"); + if (signInBtnMobile) signInBtnMobile.onclick = openModal; + + var dashBtnMobile = document.getElementById("auth-logout-btn-mobile"); + if (dashBtnMobile) dashBtnMobile.onclick = openDashboard; }); })(); From 07bab550582863ea490f206bca35e0c9bf0257ff Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:45:42 +0530 Subject: [PATCH 14/20] feat: update learning_path.py for data persistence --- src/utils/learning_path.py | 103 +++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/src/utils/learning_path.py b/src/utils/learning_path.py index 6ead6299..83affe01 100644 --- a/src/utils/learning_path.py +++ b/src/utils/learning_path.py @@ -8,10 +8,8 @@ # must present the same token; requests with a missing or wrong token are # rejected with a 403 status before any data is returned or modified. # -# Storage is intentionally in-memory so the module has no external -# dependencies beyond the Python standard library. Data does not survive -# an application restart, which is acceptable for this project's scope and -# is clearly documented in the API contract. +# Storage is SQLite-backed (same devpath.db used by utils/auth.py) so +# progress survives an application restart. # # Public surface: # create_learning_path(path_id, token, data) -> None (raises on conflict) @@ -25,15 +23,44 @@ # PathAlreadyExistsError – path_id is already registered (on create) # AuthorizationError – token does not match the stored token +import json +import os import re import secrets +import sqlite3 # --------------------------------------------------------------------------- -# Module-level storage +# Database setup — shares devpath.db with utils/auth.py # --------------------------------------------------------------------------- -# Map of path_id -> {"token": str, "data": dict} -_store: dict = {} +_DB_PATH = os.getenv( + "DEVPATH_DB", + os.path.join(os.path.dirname(__file__), "..", "..", "devpath.db") +) + + +def _connect(): + """Open a new SQLite connection with row access by column name.""" + conn = sqlite3.connect(_DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def _init_db(): + """Create the learning_paths table if it doesn't already exist.""" + with _connect() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS learning_paths ( + path_id TEXT PRIMARY KEY, + token TEXT NOT NULL, + data TEXT NOT NULL + ) + """) + conn.commit() + + +# init on import +_init_db() # Maximum byte length accepted for a path_id to prevent abuse _MAX_PATH_ID_LEN = 128 @@ -112,13 +139,18 @@ def create_learning_path(path_id: str, token: str, data: dict) -> None: _validate_token(token) _validate_data(data) - if path_id in _store: + try: + with _connect() as conn: + conn.execute( + "INSERT INTO learning_paths (path_id, token, data) VALUES (?, ?, ?)", + (path_id, token, json.dumps(data)) + ) + conn.commit() + except sqlite3.IntegrityError: raise PathAlreadyExistsError( f"A learning path with id '{path_id}' already exists." ) - _store[path_id] = {"token": token, "data": dict(data)} - def get_learning_path(path_id: str, token: str) -> dict: """Return the data payload for a learning path. @@ -131,19 +163,22 @@ def get_learning_path(path_id: str, token: str) -> dict: _validate_path_id(path_id) _validate_token(token) - if path_id not in _store: + with _connect() as conn: + row = conn.execute( + "SELECT token, data FROM learning_paths WHERE path_id = ?", (path_id,) + ).fetchone() + + if not row: raise PathNotFoundError( f"No learning path found with id '{path_id}'." ) - stored = _store[path_id] - if not _tokens_equal(stored["token"], token): + if not _tokens_equal(row["token"], token): raise AuthorizationError( "The provided token does not match the owner token for this path." ) - # Return a copy so callers cannot mutate the stored state directly - return dict(stored["data"]) + return json.loads(row["data"]) def update_learning_path(path_id: str, token: str, data: dict) -> None: @@ -160,18 +195,26 @@ def update_learning_path(path_id: str, token: str, data: dict) -> None: _validate_token(token) _validate_data(data) - if path_id not in _store: - raise PathNotFoundError( - f"No learning path found with id '{path_id}'." - ) + with _connect() as conn: + row = conn.execute( + "SELECT token FROM learning_paths WHERE path_id = ?", (path_id,) + ).fetchone() - stored = _store[path_id] - if not _tokens_equal(stored["token"], token): - raise AuthorizationError( - "The provided token does not match the owner token for this path." - ) + if not row: + raise PathNotFoundError( + f"No learning path found with id '{path_id}'." + ) - stored["data"] = dict(data) + if not _tokens_equal(row["token"], token): + raise AuthorizationError( + "The provided token does not match the owner token for this path." + ) + + conn.execute( + "UPDATE learning_paths SET data = ? WHERE path_id = ?", + (json.dumps(data), path_id) + ) + conn.commit() def path_exists(path_id: str) -> bool: @@ -182,7 +225,11 @@ def path_exists(path_id: str) -> bool: """ if not isinstance(path_id, str): return False - return path_id in _store + with _connect() as conn: + row = conn.execute( + "SELECT 1 FROM learning_paths WHERE path_id = ?", (path_id,) + ).fetchone() + return row is not None def _clear_all() -> None: @@ -191,4 +238,6 @@ def _clear_all() -> None: This function exists solely for test isolation. It must not be called from application code. """ - _store.clear() + with _connect() as conn: + conn.execute("DELETE FROM learning_paths") + conn.commit() From a1d64d2071972d78fe67c416c54db6a0827e26d9 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:48:28 +0530 Subject: [PATCH 15/20] fix: server progress change for data persistence --- src/static/auth.js | 70 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/static/auth.js b/src/static/auth.js index a704311a..c1efc253 100644 --- a/src/static/auth.js +++ b/src/static/auth.js @@ -111,18 +111,66 @@ interceptBookmarks(); }); - // Merge a progress object pulled from the server into window.progress - // and refresh the on-page widgets that depend on it. - function applyServerProgress(serverData) { - if (!serverData || !window.progress) return; - window.progress = Object.assign(window.progress, serverData); - if (Array.isArray(serverData.viewedProjects)) window.progress.viewedProjects = serverData.viewedProjects; - if (Array.isArray(serverData.completedProjects)) window.progress.completedProjects = serverData.completedProjects; - if (Array.isArray(serverData.achievements)) window.progress.achievements = serverData.achievements; - if (serverData.badges) window.progress.badges = Object.assign(window.progress.badges || {}, serverData.badges); - if (typeof window.computeProgressPoints === "function") window.computeProgressPoints(); - if (typeof window.updateProfileWidgets === "function") window.updateProfileWidgets(); +// Merge a progress object pulled from the server into window.progress +// and refresh the on-page widgets that depend on it. +// +// This must be a non-destructive merge: script.js may have already +// recorded an action (view, search, code open, completion) on this very +// page load, before this pull finished and before saveProgressState was +// overridden below. A naive overwrite would stomp that fresh progress +// with the older server copy. Instead we OR badges, union arrays, and +// take the max of counters, then push the corrected result back up. +function applyServerProgress(serverData) { + if (!serverData || !window.progress) return; + var p = window.progress; + + function unionArrays(a, b, keyFn) { + var seen = {}; + var out = []; + (a || []).concat(b || []).forEach(function(item) { + var k = keyFn ? keyFn(item) : item; + if (!seen[k]) { seen[k] = true; out.push(item); } + }); + return out; + } + + if (Array.isArray(serverData.viewedProjects)) { + p.viewedProjects = unionArrays(p.viewedProjects, serverData.viewedProjects); + p.projectViews = p.viewedProjects.length; + } + if (Array.isArray(serverData.completedProjects)) { + p.completedProjects = unionArrays(p.completedProjects, serverData.completedProjects, + function(item) { return item && item.id ? item.id : item; }); + p.completions = p.completedProjects.length; } + if (Array.isArray(serverData.achievements)) { + p.achievements = unionArrays(p.achievements, serverData.achievements, + function(item) { return item.title; }).slice(0, 5); + } + + // Badges: a badge earned either locally or on the server stays earned. + if (serverData.badges) { + p.badges = p.badges || {}; + Object.keys(serverData.badges).forEach(function(key) { + p.badges[key] = !!p.badges[key] || !!serverData.badges[key]; + }); + } + + // searches/codeOpens don't have arrays to union from, so take the max + // of local (this page's session) vs server (other sessions/devices). + ["searches", "codeOpens"].forEach(function(key) { + if (typeof serverData[key] === "number") { + p[key] = Math.max(p[key] || 0, serverData[key]); + } + }); + + if (typeof window.computeProgressPoints === "function") window.computeProgressPoints(); + if (typeof window.updateProfileWidgets === "function") window.updateProfileWidgets(); + + // Push the corrected, merged state back to the server immediately so + // this page's freshly-earned progress is never silently lost. + if (typeof window.saveProgressState === "function") window.saveProgressState(); +} // ---- block saves + badges when signed out ---- From d3ace3fdf301564a1b47017abff70f08bcd8c1f3 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:49:25 +0530 Subject: [PATCH 16/20] fix: badges and progress persistence logic --- src/routes/main_routes.py | 86 +++++++++++++++------------------------ 1 file changed, 33 insertions(+), 53 deletions(-) diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py index 172d976c..b8bb66e6 100644 --- a/src/routes/main_routes.py +++ b/src/routes/main_routes.py @@ -320,34 +320,42 @@ def _extract_token(req): return req.headers.get(_TOKEN_HEADER, "").strip() or None -@main.route("/api/learning-path/", methods=["POST"]) -def create_path(path_id): - """Create a new learning path and bind it to the supplied token. - - Request headers: - X-Learning-Path-Token (required) - the secret token chosen by the - client (should be a random UUID or similar). +def _resolve_owner(req): + """Resolve the session token in the request into a stable owner key. - Request body (JSON): - Any JSON object representing the initial learning-path state. + auth.js sends the login session token in the X-Learning-Path-Token + header. Session tokens rotate on every login, but a learning path must + stay accessible across logins, so we translate the token into the + permanent username and use *that* as the ownership key stored/checked + in learning_path.py — never the raw, rotating token itself. - Response 201: {"path_id": "", "message": "Learning path created."} - Response 400: malformed request body or invalid path_id / token format. - Response 409: a learning path with this path_id already exists. + Returns (owner, error_response). error_response is None on success. """ - token = _extract_token(request) + token = _extract_token(req) if not token: - return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + return None, (jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400) + + username = get_user_from_token(token) + if not username: + return None, (jsonify({"error": "Invalid or expired session."}), 401) + + return username, None + + +@main.route("/api/learning-path/", methods=["POST"]) +def create_path(path_id): + owner, err = _resolve_owner(request) + if err: + return err payload = request.get_json(silent=True) if payload is None: return jsonify({"error": "Request body must be valid JSON."}), 400 - if not isinstance(payload, dict): return jsonify({"error": "Request body must be a JSON object."}), 400 try: - create_learning_path(path_id, token, payload) + create_learning_path(path_id, owner, payload) except ValueError as exc: return jsonify({"error": str(exc)}), 400 except PathAlreadyExistsError: @@ -358,23 +366,12 @@ def create_path(path_id): @main.route("/api/learning-path/", methods=["GET"]) def read_path(path_id): - """Return the data payload for a learning path. - - Request headers: - X-Learning-Path-Token (required) - the token associated with this - path when it was created. - - Response 200: {"path_id": "", "data": { ... }} - Response 400: token header missing or path_id format invalid. - Response 403: token does not match the owner token. - Response 404: no learning path found for this path_id. - """ - token = _extract_token(request) - if not token: - return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + owner, err = _resolve_owner(request) + if err: + return err try: - data = get_learning_path(path_id, token) + data = get_learning_path(path_id, owner) except ValueError as exc: return jsonify({"error": str(exc)}), 400 except PathNotFoundError: @@ -387,33 +384,18 @@ def read_path(path_id): @main.route("/api/learning-path/", methods=["PUT"]) def update_path(path_id): - """Overwrite the data payload for an existing learning path. - - Request headers: - X-Learning-Path-Token (required) - the token associated with this - path when it was created. - - Request body (JSON): - Any JSON object representing the new learning-path state. - - Response 200: {"path_id": "", "message": "Learning path updated."} - Response 400: malformed request body, missing token, or invalid format. - Response 403: token does not match the owner token. - Response 404: no learning path found for this path_id. - """ - token = _extract_token(request) - if not token: - return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400 + owner, err = _resolve_owner(request) + if err: + return err payload = request.get_json(silent=True) if payload is None: return jsonify({"error": "Request body must be valid JSON."}), 400 - if not isinstance(payload, dict): return jsonify({"error": "Request body must be a JSON object."}), 400 try: - update_learning_path(path_id, token, payload) + update_learning_path(path_id, owner, payload) except ValueError as exc: return jsonify({"error": str(exc)}), 400 except PathNotFoundError: @@ -431,13 +413,11 @@ def update_path(path_id): # On login the client gets back a session token + the user's learning-path ID # so it can immediately sync progress via the /api/learning-path/* endpoints. # --------------------------------------------------------------------------- - from utils.auth import ( register_user, login_user, get_user_from_token, - get_user_path_id, logout_user, AuthError + get_user_path_id, get_user_path_token, logout_user, AuthError ) - @main.route("/api/auth/register", methods=["POST"]) def auth_register(): """Create a new account from a JSON body of {username, password}. From bcbb362101fe2845103a0f9aa8a66edca75c981a Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:50:50 +0530 Subject: [PATCH 17/20] fix: functions update to store permenant learning path --- src/utils/auth.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/utils/auth.py b/src/utils/auth.py index 566e46d9..2d5a1c31 100644 --- a/src/utils/auth.py +++ b/src/utils/auth.py @@ -42,10 +42,11 @@ def _init_db(): with _connect() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - pw_hash TEXT NOT NULL, - salt TEXT NOT NULL, - path_id TEXT NOT NULL + username TEXT PRIMARY KEY, + pw_hash TEXT NOT NULL, + salt TEXT NOT NULL, + path_id TEXT NOT NULL, + path_token TEXT NOT NULL DEFAULT '' ) """) conn.execute(""" @@ -80,17 +81,18 @@ def register_user(username, password): if not password or len(password) < 4: raise AuthError("Password must be at least 4 characters.") - salt = secrets.token_hex(16) - pw_hash = _hash_password(password, salt) - path_id = "user-" + secrets.token_urlsafe(16) - token = secrets.token_urlsafe(32) + salt = secrets.token_hex(16) + pw_hash = _hash_password(password, salt) + path_id = "user-" + secrets.token_urlsafe(16) + path_token = secrets.token_urlsafe(32) # permanent, never rotates + token = secrets.token_urlsafe(32) # session token, rotates on login try: with _connect() as conn: conn.execute( - "INSERT INTO users (username, pw_hash, salt, path_id) " - "VALUES (?, ?, ?, ?)", - (username, pw_hash, salt, path_id) + "INSERT INTO users (username, pw_hash, salt, path_id, path_token) " + "VALUES (?, ?, ?, ?, ?)", + (username, pw_hash, salt, path_id, path_token) ) conn.execute( "INSERT INTO sessions (token, username) VALUES (?, ?)", @@ -154,6 +156,22 @@ def get_user_path_id(username): ).fetchone() return row["path_id"] if row else None +def get_user_path_token(username): + """Return the permanent learning-path token for a user, or None. + + Unlike the session token, this token never rotates. It is the token + that was used to create the learning-path entry and must be used for + all subsequent learning-path API calls for this account. + """ + if not username: + return None + with _connect() as conn: + row = conn.execute( + "SELECT path_token FROM users WHERE username = ?", + ((username or "").strip().lower(),) + ).fetchone() + return row["path_token"] if row else None + def logout_user(token): """Delete the session for a token, if it exists. No-op on empty token.""" if not token: From 1144406da516790f52a879d5f40dd52bc50a2829 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:51:38 +0530 Subject: [PATCH 18/20] style: mobile navbar update styling --- src/static/style.css | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/static/style.css b/src/static/style.css index 1dda852d..17c46281 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -781,9 +781,9 @@ html[data-entry-anim="true"] .form-card form > .btn-submit { animat left: 0; width: 100%; height: 100vh; + overflow-y: auto; padding: 100px 32px 20px; background: var(--indigo-900); - border-top: 1px solid rgba(255,255,255,0.06); z-index: 9999; } @@ -843,6 +843,23 @@ html[data-entry-anim="true"] .form-card form > .btn-submit { animat border-bottom: none; } +/* Auth buttons in the mobile menu need browser button defaults reset + so they match the link styling of the other nav-mobile-link items. */ +button.nav-mobile-link { + display: block; + width: 100%; + background: none; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; + cursor: pointer; + font-family: inherit; +} + +button.nav-mobile-link:last-child { + border-bottom: none; +} + /* Responsive navbar — tighten before switching to mobile menu */ @media (max-width: 1100px) { .nav-links { From f017d017ea1863abb7004006b6563a1aeed71a83 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 12:56:00 +0530 Subject: [PATCH 19/20] fix: mobile auth options alignment fix --- src/templates/index.html | 3069 +++++++++++++++++++++++--------------- 1 file changed, 1907 insertions(+), 1162 deletions(-) diff --git a/src/templates/index.html b/src/templates/index.html index 3a3154b6..b8ee4e91 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,1322 +1,2067 @@ - + - - - - - - DevPath — Find Projects Based On Your Skills - - - - - - - - - - - - - - - - - - - - {% include 'partials/theme_head.html' %} - - - - - - - - - - - - + - -
-
-
-
- - Open Source Developer Tool -
+
+
+
+
+ + Open Source Developer Tool +
-

- Build Projects
- That Actually Matter -

+

+ Build Projects
+ That Actually Matter +

-

- Tell DevPath what you know, what interests you, and how much time - you have. Get matched to real coding projects with step-by-step - roadmaps and ready-to-run starter code. -

- - -
+

+ Tell DevPath what you know, what interests you, and how much time + you have. Get matched to real coding projects with step-by-step + roadmaps and ready-to-run starter code. +

-
-
-
- - - - -
-
- Starter Code Ready - Download and start immediately +
-
-
- - - - expense_tracker.py -
-
- def add_expense(category, amount): - # Save entry to CSV - date = datetime.now() - with open(DATA_FILE) as f: - writer.writerow([date, ...]) -   - def monthly_summary(): - # TODO: implement - pass +
+
+
+ + + + +
+
+ Starter Code Ready + Download and start immediately +
-
-
-
- - - +
+
+ + + + expense_tracker.py +
+
+ def + add_expense(category, amount): + # Save entry to CSV + date = datetime.now() + with open(DATA_FILE) + as f: + writer.writerow([date, ...]) +   + def + monthly_summary(): + # TODO: implement + pass +
-
- 7-Step Roadmap - Clear path from start to finish + +
+
+ + + +
+
+ 7-Step Roadmap + Clear path from start to finish +
-
-
-
-
-
+
+
+
+
- -
-
-
-
-
- - - -
-
- {{ stats.total_projects }} - Real Projects +
+
+
+
+
+ + + +
+
+ {{ stats.total_projects }} + Real Projects +
-
-
-
- - - -
-
- {{ stats.unique_skills }} - Unique Skills +
+
+ + + +
+
+ {{ stats.unique_skills }} + Unique Skills +
-
-
-
- - - - -
-
- {{ stats.beginner_friendly }} - Beginner Friendly +
+
+ + + + +
+
+ {{ stats.beginner_friendly }} + Beginner Friendly +
-
-
+
- -
-
-
Your DevPath Profile
-

Track Progress, Unlock Badges, and See Your Growth

-

Sign in to sync your progress across devices. Local progress is saved in your browser.

- -
-
-
- Total Points - - - - -
-
0
-
-
- 0% -
-
- -
+
+
+
Your DevPath Profile
+

+ Track Progress, Unlock Badges, and See Your Growth +

+

+ Sign in to sync your progress across devices. Local progress is saved + in your browser. +

-
-
- Badges Earned - - - -
-
-
+
-
+
- -
-
- Supports skills including: -
- Python - JavaScript - HTML / CSS - Flask - SQL - React - Node.js - pandas - C++ - Java - TypeScript - Go - Rust - C# - Kotlin +
+
+ Supports skills including: +
+ Python + JavaScript + HTML / CSS + Flask + SQL + React + Node.js + pandas + C++ + Java + TypeScript + Go + Rust + C# + Kotlin +
-
- -
-
-
How DevPath Works
-

From Your Skills to Your
Next Project in Minutes

-

Three simple steps. No account required. No fluff.

- -
-
-
01
-

Enter Your Skills

-

Type your programming skills or click quick-select chips. Add as many as you like.

-
-
- - - -
-
-
02
-

Set Your Preferences

-

Select your experience level, area of interest, and how much time you can commit.

-
-
- - - -
-
-
03
-

Get Matched Projects

-

DevPath returns your top three matched projects with roadmaps and starter code.

+
+
+
How DevPath Works
+

+ From Your Skills to Your
Next Project in Minutes +

+

+ Three simple steps. No account required. No fluff. +

+ +
+
+
01
+

Enter Your Skills

+

+ Type your programming skills or click quick-select chips. Add as + many as you like. +

+
+
+ + + +
+
+
02
+

Set Your Preferences

+

+ Select your experience level, area of interest, and how much time + you can commit. +

+
+
+ + + +
+
+
03
+

Get Matched Projects

+

+ DevPath returns your top three matched projects with roadmaps and + starter code. +

+
-
-
+ - -
-
-
What You Get
-

Everything You Need to Start Building

-

Every recommendation comes with practical resources — not just a project name.

- -
-
-
- - - - - - +
+
+
What You Get
+

Everything You Need to Start Building

+

+ Every recommendation comes with practical resources — not just a + project name. +

+ +
+
+
+ + + + + + +
+

Personalized Matches

+

+ Projects are scored against your exact skills, level, and interest + — not pulled from a generic list. +

+ Try it now
-

Personalized Matches

-

Projects are scored against your exact skills, level, and interest — not pulled from a generic list.

- Try it now -
-
-
- - - +
+
+ + + +
+

Step-by-Step Roadmaps

+

+ Each project includes a numbered roadmap so you always know what + to build next, without guessing. +

+ Explore roadmaps
-

Step-by-Step Roadmaps

-

Each project includes a numbered roadmap so you always know what to build next, without guessing.

- Explore roadmaps -
-
-
- - - - +
+
+ + + + +
+

Starter Code Included

+

+ Download a working template for every project. Skip the blank-page + problem and start building immediately. +

+ Get starter code +

+ Download a working template for every project. Skip the blank-page + problem and start building immediately. +

+ Get starter code
-

Starter Code Included

-

Download a working template for every project. Skip the blank-page problem and start building immediately.

- Get starter code -

Download a working template for every project. Skip the blank-page problem and start building immediately. -

- Get starter code
-
-
+
- -
-
-
Get Your Recommendations
-

Find The Project

-

Fill in your details below and DevPath will match you to the most relevant projects.

- -
-
-
- - -
-
- +
+
+
Get Your Recommendations
+

Find The Project

+

+ Fill in your details below and DevPath will match you to the most + relevant projects. +

- - -
- -
- -
+
+
+ + + -
-
-
- - +
+
+ + + +
+ +
+ +
+
+
+
+
+
+ + +
+
- + + + Add one or more skills you are comfortable with. Example: + Python, React, SQL, Git. + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- - - Add one or more skills you are comfortable with. Example: Python, React, SQL, Git. - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+ +
+ +
+ Select your current proficiency level such as Beginner or + Intermediate. +
+
-
-
- -
- +
+ +
+ +
+ Pick the domain you want to explore such as Web + Development, Automation, or Games. +
- Select your current proficiency level such as Beginner or Intermediate. -
-
- + + + +
- Pick the domain you want to explore such as Web Development, Automation, or Games. -
+ Include coding, debugging, learning, and reviewing time in + your estimate. +
-
-
- -
- +
+ +
+ +
- Include coding, debugging, learning, and reviewing time in your estimate. -
-
- -
- -
- - -
- - + +
-
-
+
- - - -
-
-
-

Start Building.
A New Skill Awaits.

-

Find a project that challenges you and grow with every line of code.

- Find My Project +
+
+
+

+ Start Building.
A New Skill + Awaits. +

+

+ Find a project that challenges you and grow with every line of code. +

+ Find My Project +
-
-
+ - -
-
-
About Us
-

Our Story

-

DevPath was built to help learners move from scattered tutorials to projects with a clear - path, practical code, and meaningful momentum.

- -
-
-

Why it exists

-

Too many project ideas stop at the title. DevPath focuses on the next steps that make starting and - finishing feel manageable.

-
-
-

How it helps

-

Each recommendation includes a roadmap and starter code so users can spend time building instead of - searching for structure.

-
-
-

What it leads to

-

A smoother path from curiosity to a finished project, with fewer dead ends and more confidence along the - way.

+
+
+
About Us
+

Our Story

+

+ DevPath was built to help learners move from scattered tutorials to + projects with a clear path, practical code, and meaningful momentum. +

+ +
+
+

Why it exists

+

+ Too many project ideas stop at the title. DevPath focuses on the + next steps that make starting and finishing feel manageable. +

+
+
+

How it helps

+

+ Each recommendation includes a roadmap and starter code so users + can spend time building instead of searching for structure. +

+
+
+

What it leads to

+

+ A smoother path from curiosity to a finished project, with fewer + dead ends and more confidence along the way. +

+
-
-
+ - - + + +
+ - - - - - - - - - - - - + + + + + + + + - - - + + + From 70fe91e2b1d5d4d92369bd3f13fca622bca33e74 Mon Sep 17 00:00:00 2001 From: Shreya Janorkar Date: Fri, 3 Jul 2026 13:03:12 +0530 Subject: [PATCH 20/20] feat: points earned globally stay on server --- src/static/auth.js | 680 ++++++++++++++++++++++++++++----------------- 1 file changed, 422 insertions(+), 258 deletions(-) diff --git a/src/static/auth.js b/src/static/auth.js index c1efc253..05915ae4 100644 --- a/src/static/auth.js +++ b/src/static/auth.js @@ -8,24 +8,46 @@ "use strict"; var AUTH_TOKEN_KEY = "devpathAuthToken"; - var AUTH_USER_KEY = "devpathAuthUser"; - var AUTH_PATH_KEY = "devpathAuthPathId"; + var AUTH_USER_KEY = "devpathAuthUser"; + var AUTH_PATH_KEY = "devpathAuthPathId"; // Read a value from localStorage, swallowing any access errors. - function lsGet(k) { try { return localStorage.getItem(k); } catch(e) { return null; } } + function lsGet(k) { + try { + return localStorage.getItem(k); + } catch (e) { + return null; + } + } // Write a value to localStorage, swallowing any access errors. - function lsSet(k,v){ try { localStorage.setItem(k,v); } catch(e) {} } + function lsSet(k, v) { + try { + localStorage.setItem(k, v); + } catch (e) {} + } // Remove a value from localStorage, swallowing any access errors. - function lsDel(k) { try { localStorage.removeItem(k); } catch(e) {} } + function lsDel(k) { + try { + localStorage.removeItem(k); + } catch (e) {} + } // Return the current session token, or null if signed out. - function getToken() { return lsGet(AUTH_TOKEN_KEY); } + function getToken() { + return lsGet(AUTH_TOKEN_KEY); + } // Return the current signed-in username, or null. - function getUser() { return lsGet(AUTH_USER_KEY); } + function getUser() { + return lsGet(AUTH_USER_KEY); + } // Return the server-side learning-path ID tied to this account. - function getPathId() { return lsGet(AUTH_PATH_KEY); } + function getPathId() { + return lsGet(AUTH_PATH_KEY); + } // Return true when a session token is present. - function isLoggedIn(){ return !!getToken(); } + function isLoggedIn() { + return !!getToken(); + } // Expose so other scripts (e.g. bookmarks.js) can check sign-in state. window.authIsLoggedIn = isLoggedIn; @@ -47,7 +69,7 @@ toast.textContent = message; toast.classList.add("show"); window.clearTimeout(showToast.timeout); - showToast.timeout = window.setTimeout(function() { + showToast.timeout = window.setTimeout(function () { toast.classList.remove("show"); }, 2800); } @@ -57,34 +79,52 @@ // Push the full progress object to the server, creating the path first // if it doesn't exist yet (404 on PUT means "not created", so retry as POST). function pushProgressToServer(data) { - var token = getToken(), pathId = getPathId(); + var token = getToken(), + pathId = getPathId(); if (!token || !pathId) return; fetch("/api/learning-path/" + pathId, { method: "PUT", - headers: { "Content-Type": "application/json", "X-Learning-Path-Token": token }, - body: JSON.stringify(data) - }).then(function(res) { - if (res.status === 404) { - fetch("/api/learning-path/" + pathId, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Learning-Path-Token": token }, - body: JSON.stringify(data) - }); - } - }).catch(function(){}); + headers: { + "Content-Type": "application/json", + "X-Learning-Path-Token": token, + }, + body: JSON.stringify(data), + }) + .then(function (res) { + if (res.status === 404) { + fetch("/api/learning-path/" + pathId, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Learning-Path-Token": token, + }, + body: JSON.stringify(data), + }); + } + }) + .catch(function () {}); } // Fetch the saved progress object for the current account from the server. function pullProgressFromServer(cb) { - var token = getToken(), pathId = getPathId(); - if (!token || !pathId) { cb(null); return; } + var token = getToken(), + pathId = getPathId(); + if (!token || !pathId) { + cb(null); + return; + } fetch("/api/learning-path/" + pathId, { - headers: { "X-Learning-Path-Token": token } - }).then(function(res) { - return res.ok ? res.json() : null; - }).then(function(body) { - cb(body && body.data ? body.data : null); - }).catch(function() { cb(null); }); + headers: { "X-Learning-Path-Token": token }, + }) + .then(function (res) { + return res.ok ? res.json() : null; + }) + .then(function (body) { + cb(body && body.data ? body.data : null); + }) + .catch(function () { + cb(null); + }); } // ---- patch script.js progress functions ---- @@ -92,9 +132,9 @@ // On page load, pull server progress (if signed in) and apply it; if signed // out, wipe any stale localStorage progress so it stays blank. - window.addEventListener("load", function() { + window.addEventListener("load", function () { if (typeof window.saveProgressState === "function") { - window.saveProgressState = function() { + window.saveProgressState = function () { if (isLoggedIn() && window.progress) { pushProgressToServer(window.progress); } @@ -105,72 +145,93 @@ if (isLoggedIn()) { pullProgressFromServer(applyServerProgress); } else { - try { localStorage.removeItem("devpathUserProgress"); } catch(e) {} + try { + localStorage.removeItem("devpathUserProgress"); + } catch (e) {} } interceptBookmarks(); }); -// Merge a progress object pulled from the server into window.progress -// and refresh the on-page widgets that depend on it. -// -// This must be a non-destructive merge: script.js may have already -// recorded an action (view, search, code open, completion) on this very -// page load, before this pull finished and before saveProgressState was -// overridden below. A naive overwrite would stomp that fresh progress -// with the older server copy. Instead we OR badges, union arrays, and -// take the max of counters, then push the corrected result back up. -function applyServerProgress(serverData) { - if (!serverData || !window.progress) return; - var p = window.progress; - - function unionArrays(a, b, keyFn) { - var seen = {}; - var out = []; - (a || []).concat(b || []).forEach(function(item) { - var k = keyFn ? keyFn(item) : item; - if (!seen[k]) { seen[k] = true; out.push(item); } - }); - return out; - } + // Merge a progress object pulled from the server into window.progress + // and refresh the on-page widgets that depend on it. + // + // This must be a non-destructive merge: script.js may have already + // recorded an action (view, search, code open, completion) on this very + // page load, before this pull finished and before saveProgressState was + // overridden below. A naive overwrite would stomp that fresh progress + // with the older server copy. Instead we OR badges, union arrays, and + // take the max of counters, then push the corrected result back up. + function applyServerProgress(serverData) { + if (!serverData || !window.progress) return; + var p = window.progress; - if (Array.isArray(serverData.viewedProjects)) { - p.viewedProjects = unionArrays(p.viewedProjects, serverData.viewedProjects); - p.projectViews = p.viewedProjects.length; - } - if (Array.isArray(serverData.completedProjects)) { - p.completedProjects = unionArrays(p.completedProjects, serverData.completedProjects, - function(item) { return item && item.id ? item.id : item; }); - p.completions = p.completedProjects.length; - } - if (Array.isArray(serverData.achievements)) { - p.achievements = unionArrays(p.achievements, serverData.achievements, - function(item) { return item.title; }).slice(0, 5); - } + function unionArrays(a, b, keyFn) { + var seen = {}; + var out = []; + (a || []).concat(b || []).forEach(function (item) { + var k = keyFn ? keyFn(item) : item; + if (!seen[k]) { + seen[k] = true; + out.push(item); + } + }); + return out; + } - // Badges: a badge earned either locally or on the server stays earned. - if (serverData.badges) { - p.badges = p.badges || {}; - Object.keys(serverData.badges).forEach(function(key) { - p.badges[key] = !!p.badges[key] || !!serverData.badges[key]; - }); - } + if (Array.isArray(serverData.viewedProjects)) { + p.viewedProjects = unionArrays( + p.viewedProjects, + serverData.viewedProjects, + ); + p.projectViews = p.viewedProjects.length; + } + if (Array.isArray(serverData.completedProjects)) { + p.completedProjects = unionArrays( + p.completedProjects, + serverData.completedProjects, + function (item) { + return item && item.id ? item.id : item; + }, + ); + p.completions = p.completedProjects.length; + } + if (Array.isArray(serverData.achievements)) { + p.achievements = unionArrays( + p.achievements, + serverData.achievements, + function (item) { + return item.title; + }, + ).slice(0, 5); + } - // searches/codeOpens don't have arrays to union from, so take the max - // of local (this page's session) vs server (other sessions/devices). - ["searches", "codeOpens"].forEach(function(key) { - if (typeof serverData[key] === "number") { - p[key] = Math.max(p[key] || 0, serverData[key]); + // Badges: a badge earned either locally or on the server stays earned. + if (serverData.badges) { + p.badges = p.badges || {}; + Object.keys(serverData.badges).forEach(function (key) { + p.badges[key] = !!p.badges[key] || !!serverData.badges[key]; + }); } - }); - if (typeof window.computeProgressPoints === "function") window.computeProgressPoints(); - if (typeof window.updateProfileWidgets === "function") window.updateProfileWidgets(); + // searches/codeOpens don't have arrays to union from, so take the max + // of local (this page's session) vs server (other sessions/devices). + ["searches", "codeOpens"].forEach(function (key) { + if (typeof serverData[key] === "number") { + p[key] = Math.max(p[key] || 0, serverData[key]); + } + }); - // Push the corrected, merged state back to the server immediately so - // this page's freshly-earned progress is never silently lost. - if (typeof window.saveProgressState === "function") window.saveProgressState(); -} + if (typeof window.computeProgressPoints === "function") + window.computeProgressPoints(); + if (typeof window.updateProfileWidgets === "function") + window.updateProfileWidgets(); + + // Push the corrected, merged state back to the server immediately so + // this page's freshly-earned progress is never silently lost. + if (typeof window.saveProgressState === "function") + window.saveProgressState(); + } // ---- block saves + badges when signed out ---- @@ -188,15 +249,21 @@ function applyServerProgress(serverData) { // earning the related badges) requires being signed in. function interceptBookmarks() { if (!window.DevPathBookmarks) return; - var origSave = window.DevPathBookmarks.save; + var origSave = window.DevPathBookmarks.save; var origToggle = window.DevPathBookmarks.toggle; - window.DevPathBookmarks.save = function(project) { - if (!isLoggedIn()) { showSignInPrompt("Sign in to save projects."); return false; } + window.DevPathBookmarks.save = function (project) { + if (!isLoggedIn()) { + showSignInPrompt("Sign in to save projects."); + return false; + } return origSave.call(window.DevPathBookmarks, project); }; - window.DevPathBookmarks.toggle = function(project, button) { - if (!isLoggedIn()) { showSignInPrompt("Sign in to save projects."); return; } + window.DevPathBookmarks.toggle = function (project, button) { + if (!isLoggedIn()) { + showSignInPrompt("Sign in to save projects."); + return; + } return origToggle.call(window.DevPathBookmarks, project, button); }; } @@ -207,34 +274,48 @@ function applyServerProgress(serverData) { // a session (search bar, recommendation form, skill chips, bookmarks). function clearAndResetUI() { if (window.progress) { - window.progress.searches = 0; window.progress.projectViews = 0; - window.progress.codeOpens = 0; window.progress.completions = 0; - window.progress.points = 0; window.progress.bestScore = 0; - window.progress.viewedProjects = []; window.progress.completedProjects = []; + window.progress.searches = 0; + window.progress.projectViews = 0; + window.progress.codeOpens = 0; + window.progress.completions = 0; + window.progress.points = 0; + window.progress.bestScore = 0; + window.progress.viewedProjects = []; + window.progress.completedProjects = []; window.progress.achievements = []; window.progress.badges = { - first_search: false, project_explorer: false, - code_starter: false, completionist: false, roadmap_runner: false + first_search: false, + project_explorer: false, + code_starter: false, + completionist: false, + roadmap_runner: false, }; } - try { localStorage.removeItem("devpathUserProgress"); } catch(e) {} + try { + localStorage.removeItem("devpathUserProgress"); + } catch (e) {} var topicSearch = document.getElementById("topic-search"); if (topicSearch) topicSearch.value = ""; - ["level", "interest", "time"].forEach(function(id) { + ["level", "interest", "time"].forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ""; }); if (typeof window.clearSkills === "function") window.clearSkills(); - if (window.DevPathBookmarks && typeof window.DevPathBookmarks.clearSession === "function") { + if ( + window.DevPathBookmarks && + typeof window.DevPathBookmarks.clearSession === "function" + ) { window.DevPathBookmarks.clearSession(); } - if (typeof window.computeProgressPoints === "function") window.computeProgressPoints(); - if (typeof window.updateProfileWidgets === "function") window.updateProfileWidgets(); + if (typeof window.computeProgressPoints === "function") + window.computeProgressPoints(); + if (typeof window.updateProfileWidgets === "function") + window.updateProfileWidgets(); } // ---- logout ---- @@ -243,8 +324,8 @@ function applyServerProgress(serverData) { function doLogout() { fetch("/api/auth/logout", { method: "POST", - headers: { "X-Auth-Token": getToken() } - }).finally(function() { + headers: { "X-Auth-Token": getToken() }, + }).finally(function () { lsDel(AUTH_TOKEN_KEY); lsDel(AUTH_USER_KEY); lsDel(AUTH_PATH_KEY); @@ -266,24 +347,24 @@ function applyServerProgress(serverData) { el.innerHTML = [ '
', - '
', - '

Sign In

', - '', - '
', - - '
', - '', - '', - '
', - - '
', - - '
', - '', - '', - '', - '
', - '
' + '
', + '

Sign In

', + '', + "
", + + '
', + '', + '', + "
", + + '
', + + '
', + '', + '', + '', + "
", + "
", ].join(""); document.body.appendChild(el); @@ -292,87 +373,113 @@ function applyServerProgress(serverData) { // Attach all event handlers for the modal (tabs, submit, close, enter-key). function wireModalEvents(overlay) { - var closeBtn = document.getElementById("auth-modal-close"); - var tabLogin = document.getElementById("auth-tab-login"); - var tabReg = document.getElementById("auth-tab-register"); + var closeBtn = document.getElementById("auth-modal-close"); + var tabLogin = document.getElementById("auth-tab-login"); + var tabReg = document.getElementById("auth-tab-register"); var submitBtn = document.getElementById("auth-submit-btn"); - var errorEl = document.getElementById("auth-error"); - var titleEl = document.getElementById("auth-modal-title"); + var errorEl = document.getElementById("auth-error"); + var titleEl = document.getElementById("auth-modal-title"); var mode = "login"; // Switch the modal between login and register mode. function setMode(m) { mode = m; - titleEl.textContent = m === "login" ? "Sign In" : "Create Account"; + titleEl.textContent = m === "login" ? "Sign In" : "Create Account"; submitBtn.textContent = m === "login" ? "Sign In" : "Sign Up"; - document.getElementById("auth-password").setAttribute("autocomplete", - m === "login" ? "current-password" : "new-password"); + document + .getElementById("auth-password") + .setAttribute( + "autocomplete", + m === "login" ? "current-password" : "new-password", + ); tabLogin.classList.toggle("auth-tab--active", m === "login"); - tabReg.classList.toggle("auth-tab--active", m === "register"); + tabReg.classList.toggle("auth-tab--active", m === "register"); showError(""); } // Show or clear the inline error banner. function showError(msg) { - errorEl.textContent = msg; + errorEl.textContent = msg; errorEl.style.display = msg ? "block" : "none"; } - tabLogin.addEventListener("click", function() { setMode("login"); }); - tabReg.addEventListener("click", function() { setMode("register"); }); + tabLogin.addEventListener("click", function () { + setMode("login"); + }); + tabReg.addEventListener("click", function () { + setMode("register"); + }); closeBtn.addEventListener("click", closeModal); - overlay.addEventListener("click", function(e) { if (e.target === overlay) closeModal(); }); + overlay.addEventListener("click", function (e) { + if (e.target === overlay) closeModal(); + }); // Submit the form to /api/auth/login or /api/auth/register depending on mode. - submitBtn.addEventListener("click", function() { + submitBtn.addEventListener("click", function () { var username = document.getElementById("auth-username").value.trim(); var password = document.getElementById("auth-password").value.trim(); - if (!username || !password) { showError("Fill in both fields."); return; } + if (!username || !password) { + showError("Fill in both fields."); + return; + } - submitBtn.disabled = true; + submitBtn.disabled = true; submitBtn.textContent = "..."; fetch(mode === "login" ? "/api/auth/login" : "/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: username, password: password }) - }).then(function(res) { - return res.json().then(function(data) { return { ok: res.ok, data: data }; }); - }).then(function(result) { - submitBtn.disabled = false; - submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; - if (!result.ok) { - var msg = result.data.error || "Something went wrong."; - if (mode === "login" && msg.toLowerCase().indexOf("invalid") !== -1) { - msg = "Invalid username or password. No account yet? Switch to Sign Up."; + body: JSON.stringify({ username: username, password: password }), + }) + .then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, data: data }; + }); + }) + .then(function (result) { + submitBtn.disabled = false; + submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; + if (!result.ok) { + var msg = result.data.error || "Something went wrong."; + if ( + mode === "login" && + msg.toLowerCase().indexOf("invalid") !== -1 + ) { + msg = + "Invalid username or password. No account yet? Switch to Sign Up."; + } + showError(msg); + return; } - showError(msg); - return; - } - lsSet(AUTH_TOKEN_KEY, result.data.token); - lsSet(AUTH_USER_KEY, result.data.username); - lsSet(AUTH_PATH_KEY, result.data.path_id); - closeModal(); - updateNavUI(); - updateSubtitle(); - showToast(mode === "login" ? "Successfully signed in" : "Successfully signed up"); - pullProgressFromServer(applyServerProgress); - }).catch(function() { - submitBtn.disabled = false; - submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; - showError("Network error. Try again."); - }); + lsSet(AUTH_TOKEN_KEY, result.data.token); + lsSet(AUTH_USER_KEY, result.data.username); + lsSet(AUTH_PATH_KEY, result.data.path_id); + closeModal(); + updateNavUI(); + updateSubtitle(); + showToast( + mode === "login" + ? "Successfully signed in" + : "Successfully signed up", + ); + pullProgressFromServer(applyServerProgress); + }) + .catch(function () { + submitBtn.disabled = false; + submitBtn.textContent = mode === "login" ? "Sign In" : "Sign Up"; + showError("Network error. Try again."); + }); }); - ["auth-username", "auth-password"].forEach(function(id) { - document.getElementById(id).addEventListener("keydown", function(e) { + ["auth-username", "auth-password"].forEach(function (id) { + document.getElementById(id).addEventListener("keydown", function (e) { if (e.key === "Enter") submitBtn.click(); }); }); } // Show the sign in / sign up modal. - function openModal() { + function openModal() { var o = document.getElementById("auth-modal-overlay"); if (o) o.style.display = "flex"; } @@ -393,59 +500,63 @@ function applyServerProgress(serverData) { el.innerHTML = [ '
', - '
', - '
Dashboard
', - '
', - '
', - '', - '
', + "
", + '
Dashboard
', + '
', + "
", + '', + "
", '
', - '
', - '
', - 'Points', - '0', - '
', - '
', - '
', - - '
', - '
0
Searches
', - '
0
Viewed
', - '
0
Code Opens
', - '
0
Completed
', - '
', - - '
', - '', - '
', - '
', - - '
', - '', - '
', - '
', - - '', - '', - - '
', + '
', + '
', + 'Points', + '0', + "
", + '
', + "
", + + '
', + '
0
Searches
', + '
0
Viewed
', + '
0
Code Opens
', + '
0
Completed
', + "
", + + '
', + '', + '
', + "
", + + '
', + '', + '
', + "
", + + '', + '", + + "
", '' + '', + "", ].join(""); document.body.appendChild(el); - document.getElementById("dash-close").addEventListener("click", closeDashboard); - document.getElementById("dash-logout").addEventListener("click", function() { - closeDashboard(); - doLogout(); - }); + document + .getElementById("dash-close") + .addEventListener("click", closeDashboard); + document + .getElementById("dash-logout") + .addEventListener("click", function () { + closeDashboard(); + doLogout(); + }); } // Slide the dashboard panel into view and refresh its contents. @@ -467,9 +578,10 @@ function applyServerProgress(serverData) { // Return the CSS class for a level tag based on its value. function levelTagClass(level) { - if (level === "Beginner") return "auth-tag auth-tag--level-beginner"; - if (level === "Intermediate") return "auth-tag auth-tag--level-intermediate"; - if (level === "Advanced") return "auth-tag auth-tag--level-advanced"; + if (level === "Beginner") return "auth-tag auth-tag--level-beginner"; + if (level === "Intermediate") + return "auth-tag auth-tag--level-intermediate"; + if (level === "Advanced") return "auth-tag auth-tag--level-advanced"; return "auth-tag"; } @@ -479,17 +591,28 @@ function applyServerProgress(serverData) { var p = window.progress; if (!p) return; - var u = document.getElementById("dash-username"); if (u) u.textContent = getUser() || ""; - var pt = document.getElementById("dash-points"); if (pt) pt.textContent = p.points || 0; - var m = document.getElementById("dash-meter"); + var u = document.getElementById("dash-username"); + if (u) u.textContent = getUser() || ""; + var pt = document.getElementById("dash-points"); + if (pt) pt.textContent = p.points || 0; + var m = document.getElementById("dash-meter"); if (m) { - var pct = Math.min(100, Math.round(((p.points||0) / (window.PROGRESS_MAX_POINTS||500)) * 100)); + var pct = Math.min( + 100, + Math.round( + ((p.points || 0) / (window.PROGRESS_MAX_POINTS || 500)) * 100, + ), + ); m.style.width = pct + "%"; } - var s = document.getElementById("dash-stat-searches"); if (s) s.textContent = p.searches || 0; - var v = document.getElementById("dash-stat-viewed"); if (v) v.textContent = p.projectViews || 0; - var co = document.getElementById("dash-stat-codeopens"); if (co) co.textContent = p.codeOpens || 0; - var cp = document.getElementById("dash-stat-completed"); if (cp) cp.textContent = p.completions || 0; + var s = document.getElementById("dash-stat-searches"); + if (s) s.textContent = p.searches || 0; + var v = document.getElementById("dash-stat-viewed"); + if (v) v.textContent = p.projectViews || 0; + var co = document.getElementById("dash-stat-codeopens"); + if (co) co.textContent = p.codeOpens || 0; + var cp = document.getElementById("dash-stat-completed"); + if (cp) cp.textContent = p.completions || 0; renderDashboardBadges(p.badges || {}); renderDashboardSaved(); @@ -500,16 +623,31 @@ function applyServerProgress(serverData) { var badgesEl = document.getElementById("dash-badges"); if (!badgesEl) return; var defs = [ - ["first_search","First Search"],["project_explorer","Project Explorer"], - ["code_starter","Code Starter"],["completionist","Completionist"],["roadmap_runner","Roadmap Runner"] + ["first_search", "First Search"], + ["project_explorer", "Project Explorer"], + ["code_starter", "Code Starter"], + ["completionist", "Completionist"], + ["roadmap_runner", "Roadmap Runner"], ]; - badgesEl.innerHTML = defs.map(function(b) { - var unlocked = !!badges[b[0]]; - return '
' + - '' + (unlocked ? "✅" : "🔒") + '' + - '' + b[1] + '' + - '
'; - }).join(""); + badgesEl.innerHTML = defs + .map(function (b) { + var unlocked = !!badges[b[0]]; + return ( + '
' + + "" + + (unlocked ? "✅" : "🔒") + + "" + + '' + + b[1] + + "" + + "
" + ); + }) + .join(""); } // Render the saved-projects list inside the dashboard as mini project cards @@ -517,45 +655,72 @@ function applyServerProgress(serverData) { function renderDashboardSaved() { var savedEl = document.getElementById("dash-saved"); if (!savedEl) return; - var saved = window.DevPathBookmarks ? window.DevPathBookmarks.getSaved() : []; + var saved = window.DevPathBookmarks + ? window.DevPathBookmarks.getSaved() + : []; if (!saved.length) { - savedEl.innerHTML = 'No saved projects yet.'; + savedEl.innerHTML = + 'No saved projects yet.'; return; } - savedEl.innerHTML = saved.slice(0, 5).map(function(proj) { - var tags = (Array.isArray(proj.skills) ? proj.skills.slice(0, 3) : []) - .map(function(skill) { return '' + skill + ''; }) - .join(""); - if (proj.level) tags += '' + proj.level + ''; - if (proj.time) tags += '' + proj.time + ''; - - return '' + - '
' + - '' + proj.title + '' + - (tags ? '
' + tags + '
' : '') + - '
' + - '
'; - }).join(""); + savedEl.innerHTML = saved + .slice(0, 5) + .map(function (proj) { + var tags = (Array.isArray(proj.skills) ? proj.skills.slice(0, 3) : []) + .map(function (skill) { + return '' + skill + ""; + }) + .join(""); + if (proj.level) + tags += + '' + + proj.level + + ""; + if (proj.time) + tags += + '' + proj.time + ""; + + return ( + '' + + '
' + + '' + + proj.title + + "" + + (tags ? '
' + tags + "
" : "") + + "
" + + "
" + ); + }) + .join(""); } - // ---- nav UI ---- + // ---- nav UI ---- + + // Show/hide the Sign In button and Dashboard button in the navbar + // based on current sign-in state. + // ---- nav UI ---- // Show/hide the Sign In button and Dashboard button in both the desktop // navbar and the mobile menu based on current sign-in state. function updateNavUI() { - var signInBtn = document.getElementById("auth-nav-btn"); - var dashBtn = document.getElementById("auth-logout-btn"); + var signInBtn = document.getElementById("auth-nav-btn"); + var dashBtn = document.getElementById("auth-logout-btn"); var signInBtnMobile = document.getElementById("auth-nav-btn-mobile"); - var dashBtnMobile = document.getElementById("auth-logout-btn-mobile"); + var dashBtnMobile = document.getElementById("auth-logout-btn-mobile"); if (!signInBtn) return; var loggedIn = isLoggedIn(); signInBtn.classList.toggle("auth-hidden", loggedIn); - if (dashBtn) dashBtn.classList.toggle("auth-hidden", !loggedIn); - if (signInBtnMobile) signInBtnMobile.classList.toggle("auth-hidden", loggedIn); - if (dashBtnMobile) dashBtnMobile.classList.toggle("auth-hidden", !loggedIn); + if (dashBtn) dashBtn.classList.toggle("auth-hidden", !loggedIn); + if (signInBtnMobile) + signInBtnMobile.classList.toggle("auth-hidden", loggedIn); + if (dashBtnMobile) dashBtnMobile.classList.toggle("auth-hidden", !loggedIn); } // Update the progress section subtitle to reflect sign-in state. @@ -569,7 +734,7 @@ function applyServerProgress(serverData) { // ---- init ---- - document.addEventListener("DOMContentLoaded", function() { + document.addEventListener("DOMContentLoaded", function () { injectModal(); injectDashboard(); injectToast(); @@ -590,5 +755,4 @@ function applyServerProgress(serverData) { var dashBtnMobile = document.getElementById("auth-logout-btn-mobile"); if (dashBtnMobile) dashBtnMobile.onclick = openDashboard; }); - })();