diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0728e..93b83eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- "Share My Result" button on results page that copies a pre-filled URL to clipboard (#411) +- Auto-fill form and trigger recommendations when opening a shared URL (#411) - Initial CHANGELOG.md setup for tracking project history - Documentation structure for future contributor updates - Added .flake8 config file to enforce consistent 88-character line limit for all contributors diff --git a/static/script.js b/static/script.js index 8da1561..4a7c55b 100644 --- a/static/script.js +++ b/static/script.js @@ -1,134 +1,169 @@ -// script.js — DevPath client-side logic -// -// Responsibilities: -// - Mobile navigation toggle -// - Skill chip manager (add/remove skills) -// - Form validation with per-field error messages -// - Recommendation API call and loading states -// - Result card rendering -// - Code viewer panel (detail page) +// DevPath client-side behavior. -// ============================================================ -// Detect which page we are on -// ============================================================ -// !! trick turns the DOM result into a simple true/false -var isIndexPage = !!document.getElementById("recommend-form"); -// PROJECT_ID is set by the server only on detail pages, so if it's missing we're elsewhere -var isDetailPage = typeof PROJECT_ID !== "undefined"; -var modal = document.getElementById('github-modal-overlay'); -var openModalBtn = document.getElementById('btn-show-github'); // The trigger in your main form -var closeModalBtn = document.getElementById('btn-close-github'); -var fetchBtn = document.getElementById('btn-fetch-github'); -var githubInput = document.getElementById('github-username'); -var errorMsg = document.getElementById('github-modal-error'); +var POINTS_PER_SEARCH = 5; +var POINTS_PER_VIEW = 10; +var POINTS_PER_CODE_OPEN = 15; +var POINTS_PER_COMPLETION = 30; +var PROGRESS_MAX_POINTS = 450; +(function () { + var html = document.documentElement; -// ============================================================ -// Mobile navigation toggle (runs on all pages) -// ============================================================ -(function initMobileNav() { - var toggle = document.getElementById("nav-mobile-toggle"); //hamburger button - var menu = document.getElementById("nav-mobile-menu"); //dropdown menu + function applyTheme(theme) { + var isDark = theme === "dark"; + html.setAttribute("data-theme", theme); + try { + localStorage.setItem("theme", theme); + } catch (err) { + // Storage can be unavailable in private browsing. + } + + document.querySelectorAll(".theme-toggle").forEach(function (button) { + button.setAttribute("aria-pressed", isDark ? "true" : "false"); + button.setAttribute("aria-label", isDark ? "Switch to light mode" : "Switch to dark mode"); + }); + } + + function initTheme() { + var theme = html.getAttribute("data-theme") || "light"; + applyTheme(theme); + requestAnimationFrame(function () { + html.classList.add("theme-ready"); + }); + } + + document.addEventListener("click", function (event) { + var toggle = event.target.closest(".theme-toggle"); + if (!toggle) return; + event.preventDefault(); + var current = html.getAttribute("data-theme") || "light"; + applyTheme(current === "dark" ? "light" : "dark"); + }); - // Nothing to do if the nav isn't on this page, just bail out + initTheme(); +})(); + +(function initMobileNav() { + var toggle = document.getElementById("nav-mobile-toggle"); + var menu = document.getElementById("nav-mobile-menu"); if (!toggle || !menu) return; - toggle.addEventListener("click", function () { - // classList.toggle returns true if class was added, false if removed - var isOpen = menu.classList.toggle("open"); + function setOpen(isOpen) { + menu.classList.toggle("open", isOpen); toggle.classList.toggle("open", isOpen); - // Keep aria-expanded in sync so screen readers know if menu is open or closed - toggle.setAttribute("aria-expanded", isOpen); + toggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); + } + + toggle.addEventListener("click", function () { + setOpen(!menu.classList.contains("open")); }); - // Close menu when any mobile link is clicked - menu.querySelectorAll(".nav-mobile-link").forEach(function (link) { - link.addEventListener("click", function () { - menu.classList.remove("open"); - toggle.classList.remove("open"); + menu.querySelectorAll(".nav-mobile-link").forEach(function (link) { + link.addEventListener("click", function () { + setOpen(false); }); }); -})(); + window.addEventListener("resize", function () { + if (window.innerWidth >= 640) setOpen(false); + }); +})(); -// ============================================================ -// INDEX PAGE -// ============================================================ -if (isIndexPage) { - - // DOM references - // grabbing all the elements we'll need so we're not calling getElementById over and over again throughout the code - var form = document.getElementById("recommend-form"); - var submitBtn = document.getElementById("submit-btn"); - var btnLabel = document.getElementById("btn-label"); // "get recommendations" text - var btnLoading = document.getElementById("btn-loading"); // spinner icon inside the button - var resultsSection = document.getElementById("results-section"); - var resultsGrid = document.getElementById("results-grid"); - var resultsLoadingEl = document.getElementById("results-loading"); // "Loading..." text in the results - var resultsEmptyEl = document.getElementById("results-empty"); - var emptyMessageEl = document.getElementById("empty-message"); - var skillsHidden = document.getElementById("skills"); // the hidden input that holds skills list - var skillsTextInput = document.getElementById("skills-input"); //visible text box in which user types skills - var chipsSelectedEl = document.getElementById("skill-chips-selected"); //selected skills tags container - var quickPickChips = document.querySelectorAll(".skill-chip"); // predefined skills user can click - - // Tracks currently selected skills to prevent duplicates - var selectedSkills = []; - -// Points awarded per action -var POINTS_PER_SEARCH = 5; -var POINTS_PER_VIEW = 10; -var POINTS_PER_CODE_OPEN = 15; -var POINTS_PER_COMPLETION = 30; - -var PROGRESS_TARGET_SEARCHES = 10; -var PROGRESS_TARGET_VIEWS = 10; -var PROGRESS_TARGET_CODE_OPENS = 10; -var PROGRESS_TARGET_COMPLETIONS = 5; +var STORAGE_KEY = "devpathUserProgress"; +var progress = { + searches: 0, + projectViews: 0, + codeOpens: 0, + completions: 0, + points: 0, + viewedProjects: [], + completedProjects: [], + achievements: [], + badges: { + first_search: false, + project_explorer: false, + code_starter: false, + completionist: false, + roadmap_runner: false + }, + bestScore: 0 +}; + +function loadProgressState() { + try { + var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); + if (!saved || typeof saved !== "object") return; + progress = Object.assign(progress, saved); + progress.viewedProjects = Array.isArray(saved.viewedProjects) ? saved.viewedProjects : []; + progress.completedProjects = Array.isArray(saved.completedProjects) ? saved.completedProjects : []; + progress.achievements = Array.isArray(saved.achievements) ? saved.achievements : []; + progress.badges = Object.assign(progress.badges, saved.badges || {}); + } catch (err) { + console.warn("Unable to load progress state", err); + } +} -// Maximum achievable points given the targets above -var PROGRESS_MAX_POINTS = ( - PROGRESS_TARGET_SEARCHES * POINTS_PER_SEARCH + // 50 - PROGRESS_TARGET_VIEWS * POINTS_PER_VIEW + // 100 - PROGRESS_TARGET_CODE_OPENS * POINTS_PER_CODE_OPEN + // 150 - PROGRESS_TARGET_COMPLETIONS * POINTS_PER_COMPLETION // 150 -); // total = 450 +function saveProgressState() { + try { + progress.bestScore = Math.max(progress.bestScore || 0, progress.points || 0); + localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); + } catch (err) { + console.warn("Unable to save progress state", err); + } +} function computeProgressPoints() { - var raw = - progress.searches * POINTS_PER_SEARCH + - progress.projectViews * POINTS_PER_VIEW + - progress.codeOpens * POINTS_PER_CODE_OPEN + - progress.completions * POINTS_PER_COMPLETION; - // Clamp stored points so they never exceed max — prevents aria-valuenow > 100 - progress.points = Math.min(raw, PROGRESS_MAX_POINTS); + progress.points = progress.searches * POINTS_PER_SEARCH + progress.projectViews * POINTS_PER_VIEW + + progress.codeOpens * POINTS_PER_CODE_OPEN + progress.completions * POINTS_PER_COMPLETION; } - // Clear Filters Button Functionality - var clearFiltersBtn = document.getElementById("clear-filters-btn"); - if (clearFiltersBtn) { - clearFiltersBtn.addEventListener("click", function () { - var recommendForm = document.getElementById("recommend-form"); - if (recommendForm) { - recommendForm.reset(); - resetSkillSelection(); - if (skillsTextInput) skillsTextInput.focus(); - } - }); - } +function showAchievementToast(title, detail) { + var toast = document.getElementById("achievement-toast"); + if (!toast) return; + toast.textContent = ""; + var strong = document.createElement("strong"); + strong.textContent = title; + var span = document.createElement("span"); + span.textContent = detail; + toast.appendChild(strong); + toast.appendChild(span); + toast.classList.add("show"); + window.clearTimeout(showAchievementToast.timeout); + showAchievementToast.timeout = window.setTimeout(function () { + toast.classList.remove("show"); + }, 3200); +} - // Also reset skills when the native form reset event fires - form.addEventListener("reset", function () { - window.setTimeout(function () { - resetSkillSelection(); - if (skillsTextInput) skillsTextInput.focus(); - }, 0); +function addAchievement(title, detail) { + if (progress.achievements.some(function (item) { return item.title === title; })) return; + progress.achievements.unshift({ + title: title, + description: detail, + date: new Date().toLocaleDateString() }); + progress.achievements = progress.achievements.slice(0, 5); +} +function unlockBadge(id, title, detail) { + if (progress.badges[id]) return; + progress.badges[id] = true; + addAchievement(title, detail); + showAchievementToast("Badge unlocked", title + " - " + detail); +} - // ---------------------------------------------------------- - // Skill chip manager - // ---------------------------------------------------------- +function tryUnlockBadges() { + if (progress.searches >= 1) unlockBadge("first_search", "First Search", "You used DevPath to find your first project."); + if (progress.projectViews >= 1) unlockBadge("project_explorer", "Project Explorer", "You viewed a project detail."); + if (progress.codeOpens >= 1) unlockBadge("code_starter", "Code Starter", "You opened starter code."); + if (progress.completions >= 1) unlockBadge("completionist", "Completionist", "You marked a project complete."); + if (progress.searches >= 5) unlockBadge("roadmap_runner", "Roadmap Runner", "You searched five times."); +} + +function projectIsCompleted(projectId) { + return progress.completedProjects.some(function (item) { + return (item && typeof item === "object" ? item.id : item) === projectId; + }); +} function updateProfileWidgets() { var pointsEl = document.getElementById("progress-points"); @@ -149,10 +184,7 @@ function updateProfileWidgets() { "
  • Projects Completed" + progress.completions + "
  • "; } if (meterFill) { - var percentage = Math.min( - 100, - Math.round((progress.points / PROGRESS_MAX_POINTS) * 100) - ); + var percentage = Math.min(100, Math.round((progress.points / PROGRESS_MAX_POINTS) * 100)); meterFill.style.width = percentage + "%"; meterFill.setAttribute("aria-valuenow", String(percentage)); meterFill.textContent = percentage + "%"; @@ -165,10 +197,46 @@ function updateProfileWidgets() { ["completionist", "Completionist"], ["roadmap_runner", "Roadmap Runner"] ]; + badgesEl.innerHTML = badges.map(function (badge) { + var unlocked = progress.badges[badge[0]]; + return "
  • " + (unlocked ? "OK" : "*") + "" + badge[1] + "
  • "; + }).join(""); + } + if (achievementList) { + achievementList.innerHTML = progress.achievements.length + ? progress.achievements.map(function (item) { + return "
  • " + item.title + "" + + item.description + "" + item.date + "
  • "; + }).join("") + : "
  • No achievements yet. Use DevPath and unlock the first badge.
  • "; + } + if (leaderboardList) { + var entries = [ + { name: "Ava", points: 245 }, + { name: "Kai", points: 192 }, + { name: "Sam", points: 176 }, + { name: "You", points: progress.points } + ].sort(function (a, b) { return b.points - a.points; }); + leaderboardList.innerHTML = entries.map(function (entry, index) { + return "
  • " + (index + 1) + ". " + entry.name + "" + entry.points + " pts
  • "; + }).join(""); + } + if (historyList) { + historyList.innerHTML = progress.completedProjects.length + ? progress.completedProjects.slice(0, 5).map(function (item) { + var title = item && typeof item === "object" ? item.title : "Project " + item; + return "
  • " + title + "Completed
  • "; + }).join("") + : "
  • No completed projects yet. Mark one complete from a project page.
  • "; + } + if (completionBtn && typeof PROJECT_ID !== "undefined") { + var completed = projectIsCompleted(PROJECT_ID); + completionBtn.textContent = completed ? "Project Completed" : "Mark Project Complete"; + completionBtn.disabled = completed; } } - function recordSearch() { progress.searches += 1; computeProgressPoints(); @@ -177,26 +245,42 @@ function recordSearch() { updateProfileWidgets(); } - var suggestionsDiv = document.getElementById("skills-suggestions"); - var skillWrap = document.getElementById("skill-input-wrap"); - var visibleSuggestions = []; - var activeSuggestionIndex = -1; - - // Deduplicate available skills (case-insensitive) - availableSkills = availableSkills.filter(function (skill, index, list) { - return typeof skill === "string" && skill.trim() && - list.findIndex(function (item) { - return item.toLowerCase() === skill.toLowerCase(); - }) === index; - }); +function recordProjectView() { + if (typeof PROJECT_ID === "undefined") return; + if (progress.viewedProjects.indexOf(PROJECT_ID) === -1) { + progress.viewedProjects.push(PROJECT_ID); + progress.projectViews = progress.viewedProjects.length; + computeProgressPoints(); + tryUnlockBadges(); + saveProgressState(); + updateProfileWidgets(); + } +} - if (suggestionsDiv) suggestionsDiv.setAttribute("role", "listbox"); +function recordCodeOpen() { + progress.codeOpens += 1; + computeProgressPoints(); + tryUnlockBadges(); + saveProgressState(); + updateProfileWidgets(); +} - function normalizeSkill(skill) { return skill.trim().toLowerCase(); } +function recordCompletion(projectId, projectTitle) { + if (!projectId || projectIsCompleted(projectId)) return; + progress.completedProjects.push({ id: projectId, title: projectTitle || "Project " + projectId }); + progress.completions = progress.completedProjects.length; + computeProgressPoints(); + tryUnlockBadges(); + saveProgressState(); + updateProfileWidgets(); +} loadProgressState(); updateProfileWidgets(); +// ============================================================ +// INDEX PAGE +// ============================================================ (function initIndexPage() { var form = document.getElementById("recommend-form"); if (!form) return; @@ -223,222 +307,108 @@ updateProfileWidgets(); var visibleSuggestions = []; var SAVED_PROJECTS_KEY = "devpathSavedProjects"; - window.addSkill = function addSkill(rawSkill) { - var skill = canonicalSkill(rawSkill); - if (!skill || isSelected(skill)) return; - selectedSkills.push(skill); - renderSelectedChips(); - syncSkillsHiddenInput(); - updateQuickPickState(); - clearFieldError("skills-error"); - if (skillsInput) skillsInput.focus(); - }; - function normalize(value) { return String(value || "").trim().toLowerCase(); } - function getCanonicalSkill(rawSkill) { - var normalizedSkill = normalizeSkill(rawSkill); - var matched = availableSkills.find(function (s) { return normalizeSkill(s) === normalizedSkill; }); - return matched || rawSkill.trim(); - } - - function getFilteredSkills(query) { - var normalizedQuery = normalizeSkill(query); - return availableSkills.filter(function (skill) { - return normalizeSkill(skill).includes(normalizedQuery) && !isSkillSelected(skill); - }).slice(0, 8); - } - - function renderActiveSuggestion() { - if (!suggestionsDiv) return; - suggestionsDiv.querySelectorAll(".suggestion-item").forEach(function (item, index) { - var isActive = index === activeSuggestionIndex; - item.classList.toggle("suggestion-item--active", isActive); - item.setAttribute("aria-selected", isActive ? "true" : "false"); - }); - } - - function hideSuggestions() { - visibleSuggestions = []; - activeSuggestionIndex = -1; - if (suggestionsDiv) { suggestionsDiv.style.display = "none"; suggestionsDiv.innerHTML = ""; } + function syncSkillsHiddenInput() { + skillsHidden.value = JSON.stringify(selectedSkills); } - function selectSuggestion(skill) { - addSkill(skill); - skillsTextInput.value = ""; - hideSuggestions(); - skillsTextInput.focus(); + function isSelected(skill) { + return selectedSkills.some(function (item) { return normalize(item) === normalize(skill); }); } - function displaySuggestions(items) { - if (!suggestionsDiv) return; - visibleSuggestions = items; - activeSuggestionIndex = -1; - if (items.length === 0) { hideSuggestions(); return; } - suggestionsDiv.innerHTML = ""; - items.forEach(function (skill, index) { - var item = document.createElement("div"); - item.className = "suggestion-item"; - item.textContent = skill; - item.setAttribute("role", "option"); - item.setAttribute("id", "skills-suggestion-" + index); - item.setAttribute("aria-selected", "false"); - // Prevent the input blur handler from closing the menu before click runs - item.addEventListener("mousedown", function (evt) { evt.preventDefault(); }); - item.addEventListener("mouseenter", function () { activeSuggestionIndex = index; renderActiveSuggestion(); }); - item.addEventListener("click", function () { selectSuggestion(skill); }); - suggestionsDiv.appendChild(item); - }); - suggestionsDiv.style.display = "block"; - skillsTextInput.setAttribute("aria-expanded", "true"); + function canonicalSkill(rawSkill) { + var trimmed = String(rawSkill || "").trim(); + var match = availableSkills.find(function (skill) { return normalize(skill) === normalize(trimmed); }); + return match || trimmed; } function updateQuickPickState() { quickPickChips.forEach(function (chip) { - var isActive = isSkillSelected(chip.getAttribute("data-skill") || ""); - chip.classList.toggle("active", isActive); - chip.setAttribute("aria-pressed", isActive ? "true" : "false"); + var active = isSelected(chip.getAttribute("data-skill")); + chip.classList.toggle("active", active); + chip.classList.toggle("selected", active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); }); } - // Add skill on Enter key in the text input - // we intercept Enter here so it doesn't accidentally submit the whole form - skillsTextInput.addEventListener("keydown", function (evt) { - if (evt.key === "ArrowDown" || evt.key === "ArrowUp") { - if (visibleSuggestions.length === 0) displaySuggestions(getFilteredSkills(skillsTextInput.value)); - if (visibleSuggestions.length === 0) return; - evt.preventDefault(); - if (evt.key === "ArrowDown") { - activeSuggestionIndex = (activeSuggestionIndex + 1) % visibleSuggestions.length; - } else { - activeSuggestionIndex = activeSuggestionIndex <= 0 ? visibleSuggestions.length - 1 : activeSuggestionIndex - 1; - } - renderActiveSuggestion(); - return; - } - if (evt.key === "Escape") { hideSuggestions(); return; } - if (evt.key === "Enter") { - evt.preventDefault(); - if (activeSuggestionIndex >= 0 && visibleSuggestions[activeSuggestionIndex]) { - selectSuggestion(visibleSuggestions[activeSuggestionIndex]); - return; - } - if (skillsTextInput.value.trim()) { addSkill(skillsTextInput.value); skillsTextInput.value = ""; } - hideSuggestions(); - } - }); - - // Add/toggle skill on quick-pick chip click - quickPickChips.forEach(function (chip) { - chip.addEventListener("click", function () { - var skill = chip.getAttribute("data-skill"); - if (!skill) return; - if (isSkillSelected(skill)) { removeSkill(skill); } else { addSkill(skill); } - skillsTextInput.value = ""; - hideSuggestions(); + function renderSelectedChips() { + selectedChips.textContent = ""; + selectedSkills.forEach(function (skill) { + var chip = document.createElement("span"); + chip.className = "skill-chip-selected"; + chip.appendChild(document.createTextNode(skill)); + var button = document.createElement("button"); + button.type = "button"; + button.className = "skill-chip-remove"; + button.setAttribute("aria-label", "Remove " + skill); + button.textContent = "x"; + button.addEventListener("click", function (event) { + event.stopPropagation(); + removeSkill(skill); + }); + chip.appendChild(button); + selectedChips.appendChild(chip); }); - }); - - // Show suggestions on input - skillsTextInput.addEventListener("input", function (evt) { - var typedValue = evt.target.value.trim(); - if (typedValue.length === 0) { hideSuggestions(); return; } - displaySuggestions(getFilteredSkills(typedValue)); - }); - - skillsTextInput.addEventListener("focus", function () { - if (skillsTextInput.value.trim()) displaySuggestions(getFilteredSkills(skillsTextInput.value)); - }); - - // Hide suggestions when input loses focus - skillsTextInput.addEventListener("blur", function () { - setTimeout(function () { hideSuggestions(); }, 150); - }); - - if (skillWrap) { - skillWrap.addEventListener("click", function () { skillsTextInput.focus(); }); } - document.addEventListener("click", function (evt) { - if (skillWrap && !skillWrap.contains(evt.target)) hideSuggestions(); - }); + window.addSkill = function addSkill(rawSkill) { + var skill = canonicalSkill(rawSkill); + if (!skill || isSelected(skill)) return; + selectedSkills.push(skill); + renderSelectedChips(); + syncSkillsHiddenInput(); + updateQuickPickState(); + clearFieldError("skills-error"); + if (skillsInput) skillsInput.focus(); + }; function removeSkill(skill) { - // Rebuild the array without the skill that was just removed - selectedSkills = selectedSkills.filter(function (s) { return normalizeSkill(s) !== normalizeSkill(skill); }); + selectedSkills = selectedSkills.filter(function (item) { return normalize(item) !== normalize(skill); }); renderSelectedChips(); syncSkillsHiddenInput(); updateQuickPickState(); } - // recreate the selected skills chips based on the current array(selectedSkills) - // called every time we add or remove a skill - function renderSelectedChips() { - // Wipe out old chips first so we don't end up with duplicates in the UI - chipsSelectedEl.innerHTML = ""; - selectedSkills.forEach(function (skill) { - // Create a new chip element for each selected skill - var chipEl = document.createElement("span"); - chipEl.className = "skill-chip-selected"; - chipEl.textContent = skill; - - // Remove button for each chip (create lil "x" button) - var removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "skill-chip-remove"; - removeBtn.innerHTML = "×"; //'x' symbol - removeBtn.setAttribute("aria-label", "Remove " + skill); - removeBtn.addEventListener("click", function (e) { - // Stop click from bubbling up to the chip wrap's click listener - e.stopPropagation(); - removeSkill(skill); - }); - - chipEl.appendChild(removeBtn); // put x button inside the chip - chipsSelectedEl.appendChild(chipEl); //add chip to page - }); + function clearFieldError(id) { + var el = document.getElementById(id); + if (el) el.textContent = ""; } - function syncSkillsHiddenInput() { - if (!skillsHidden) return; - // Keep the hidden in sync for form serialisation - // The API expects a comma-separated string, so join the array that way - skillsHidden.value = selectedSkills.join(", "); + function showFieldError(id, message) { + var el = document.getElementById(id); + if (el) el.textContent = message; } - updateQuickPickState(); + function clearAllErrors() { + ["skills-error", "level-error", "interest-error", "time-error"].forEach(clearFieldError); + var general = document.getElementById("form-error-general"); + if (general) general.textContent = ""; + } function hideSuggestions() { visibleSuggestions = []; activeSuggestionIndex = -1; - if (suggestionsDiv) { - suggestionsDiv.style.display = "none"; - suggestionsDiv.classList.remove("show"); - suggestionsDiv.innerHTML = ""; - } - syncSuggestionsA11yState(); suggestions.style.display = "none"; suggestions.textContent = ""; skillsInput.setAttribute("aria-expanded", "false"); } - // ---------------------------------------------------------- - // Form validation - // ---------------------------------------------------------- - - //puts error msg under specific field - function showFieldError(fieldId, message) { - var el = document.getElementById(fieldId); - if (el) el.textContent = message; + function filteredSkills(query) { + var q = normalize(query); + if (!q) return []; + return availableSkills.filter(function (skill) { + return normalize(skill).indexOf(q) !== -1 && !isSelected(skill); + }).slice(0, 8); } - //clears error msg under specific field - function clearFieldError(fieldId) { - var el = document.getElementById(fieldId); - if (el) el.textContent = ""; //empty string = no error msg + function renderSuggestionState() { + suggestions.querySelectorAll(".suggestion-item").forEach(function (item, index) { + item.classList.toggle("suggestion-item--active", index === activeSuggestionIndex); + item.setAttribute("aria-selected", index === activeSuggestionIndex ? "true" : "false"); + }); } function showSuggestions(items) { @@ -452,23 +422,6 @@ updateProfileWidgets(); items.forEach(function (skill, index) { var item = document.createElement("div"); item.className = "suggestion-item"; - - // Check if skill is already selected for multi-select styling - var isSelected = isSkillSelected(skill); - if (isSelected) { - item.classList.add("selected"); - } - - item.textContent = skill; - item.setAttribute("role", "option"); - item.setAttribute("id", "skills-suggestion-" + index); - item.setAttribute("aria-selected", isSelected ? "true" : "false"); - - // Prevent the input blur handler from closing the menu before click runs. - item.addEventListener("mousedown", function (evt) { - evt.preventDefault(); - }); - item.id = "skills-suggestion-" + index; item.setAttribute("role", "option"); item.setAttribute("aria-selected", "false"); @@ -479,12 +432,6 @@ updateProfileWidgets(); renderSuggestionState(); }); item.addEventListener("click", function () { - selectSuggestion(skill); - // Keep dropdown open if clicking from dropdown (multi-select mode) - if (suggestionsDiv.classList.contains("show")) { - displaySuggestions(items); - skillsTextInput.focus(); - } window.addSkill(skill); skillsInput.value = ""; hideSuggestions(); @@ -495,13 +442,9 @@ updateProfileWidgets(); skillsInput.setAttribute("aria-expanded", "true"); } - // checks form fields and shows error messages if any required field is missing or invalid. - // Returns true if the form is valid, false otherwise function validateForm() { var valid = true; - - // Check both the array and the hidden input since skills can come from either source - if (selectedSkills.length === 0 && !skillsHidden.value.trim()) { + if (!selectedSkills.length) { showFieldError("skills-error", "Please add at least one skill."); valid = false; } @@ -509,50 +452,6 @@ updateProfileWidgets(); showFieldError("level-error", "Please select your experience level."); valid = false; } - }); - - // Add/toggle skill on quick-pick chip click - quickPickChips.forEach(function (chip) { - chip.addEventListener("click", function () { - var skill = chip.getAttribute("data-skill"); - var isAlreadySelected = selectedSkills.some(function (s) { - return s.toLowerCase() === skill.toLowerCase(); - }); - - if (isAlreadySelected) { - removeSkill(skill); - } else { - addSkill(skill); - } - hideSuggestions(); - skillsTextInput.value = ""; - }); - }); - - // Multi-select dropdown toggle functionality - var dropdownBtn = document.getElementById("skills-dropdown-toggle"); - if (dropdownBtn) { - dropdownBtn.addEventListener("click", function (e) { - e.preventDefault(); - e.stopPropagation(); - var suggestionsOpen = suggestionsDiv.style.display === "block"; - - if (suggestionsOpen) { - hideSuggestions(); - } else { - // Show all available skills in dropdown - displaySuggestions(availableSkills); - suggestionsDiv.classList.add("show"); - } - }); - } - - // Show suggestions on input - skillsTextInput.addEventListener("input", function (evt) { - var typedValue = evt.target.value.trim(); - if (typedValue.length === 0) { - hideSuggestions(); - return; if (!document.getElementById("interest").value) { showFieldError("interest-error", "Please select an area of interest."); valid = false; @@ -561,371 +460,323 @@ updateProfileWidgets(); showFieldError("time-error", "Please select your time availability."); valid = false; } - return valid; } - - document.addEventListener("click", function (evt) { - if (skillWrap && !skillWrap.contains(evt.target)) { - hideSuggestions(); - } - }); - - //add a skill to the list if it's not empty or a duplicate - function addSkill(rawSkill) { - // Clean up any extra spaces and match to canonical skill name - var skill = getCanonicalSkill(rawSkill); - // Nothing to add if string is empty after trimming - if (!skill) return; - - // Block duplicate entries (case-insensitive) - if (isSkillSelected(skill)) return; - - selectedSkills.push(skill); - renderSelectedChips(); - syncSkillsHiddenInput(); - updateQuickPickState(); - // Once a skill is added, remove the "please add a skill" error if it was showing - clearFieldError("skills-error"); - // Ensure the corresponding quick-pick chip is visually active immediately - try { - var quickChip = document.querySelector('.skill-chip[data-skill="' + skill + '"]'); - if (quickChip) { - quickChip.classList.add('active', 'selected'); - quickChip.setAttribute('aria-pressed', 'true'); - } - } catch (e) { - // ignore DOM errors - } - // Keep focus in the input so user can continue typing - if (skillsTextInput) skillsTextInput.focus(); - } - - // remove a skill from the list and update the UI accordingly - function removeSkill(skill) { - // Rebuild the array without the skill that was just removed - selectedSkills = selectedSkills.filter(function (selectedSkill) { - return normalizeSkill(selectedSkill) !== normalizeSkill(skill); - }); - renderSelectedChips(); - syncSkillsHiddenInput(); - updateQuickPickState(); - // Also clear the visual active state on the quick-pick chip if present - try { - var quickChip = document.querySelector('.skill-chip[data-skill="' + skill + '"]'); - if (quickChip) { - quickChip.classList.remove('active', 'selected'); - quickChip.setAttribute('aria-pressed', 'false'); - } - } catch (e) { - // ignore DOM errors - } - } - - // recreate the selected skills chips based on the current array(selectedSkills) - // called every time we add or remove a skill - function renderSelectedChips() { - // Wipe out old chips first so we don't end up with duplicates in the UI - chipsSelectedEl.innerHTML = ""; - selectedSkills.forEach(function (skill) { - // Create a new chip element for each selected skill - var chipEl = document.createElement("span"); - chipEl.className = "skill-chip-selected"; - chipEl.textContent = skill; - - // Remove button for each chip (create lil "x" button) - var removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "skill-chip-remove"; - removeBtn.innerHTML = "×"; //'x' symbol - removeBtn.setAttribute("aria-label", "Remove " + skill); - removeBtn.addEventListener("click", function (e) { - // Stop click from bubbling up to the chip wrap's click listener - e.stopPropagation(); - removeSkill(skill); - }); - - chipEl.appendChild(removeBtn); // put x button inside the chip - chipsSelectedEl.appendChild(chipEl); //add chip to page - }); - } - - function syncSkillsHiddenInput() { - if (!skillsHidden) { - var skillsHidden = document.getElementById("skills"); - } - } - - updateQuickPickState(); - - // ---------------------------------------------------------- - // Form validation + // Loading state // ---------------------------------------------------------- - //puts error msg under specific field - function showFieldError(fieldId, message) { - var el = document.getElementById(fieldId); - if (el) el.textContent = message; - } - - //clears error msg under specific field - function clearFieldError(fieldId) { - var el = document.getElementById(fieldId); - if (el) el.textContent = ""; //empty string = no error msg - } - - //clears all error msgs in the form, called at the start of form submission to reset any previous errors - function clearAllErrors() { - ["skills-error", "level-error", "interest-error", "time-error"].forEach(clearFieldError); - var generalErr = document.getElementById("form-error-general"); - if (generalErr) generalErr.textContent = ""; - } - - // checks form fields and shows error messages if any required field is missing or invalid. - // Returns true if the form is valid, false otherwise - function validateForm() { - var valid = true; - - // Check both the array and the hidden input since skills can come from either source - if (selectedSkills.length === 0 && !skillsHidden.value.trim()) { - showFieldError("skills-error", "Please add at least one skill."); - valid = false; - } - if (!document.getElementById("level").value) { - showFieldError("level-error", "Please select your experience level."); - valid = false; - } - if (!document.getElementById("interest").value) { - showFieldError("interest-error", "Please select an area of interest."); - valid = false; - } - if (!document.getElementById("time").value) { - showFieldError("time-error", "Please select your time availability."); - valid = false; - } - - return valid; - } - - - - // ---------------------------------------------------------- - // Form submission and API call - // ---------------------------------------------------------- - - // Manages the loading state of the form and results section(whats visible or not) function setLoadingState(isLoading) { - // Disable the button so the user can't accidentally submit twice submitBtn.disabled = isLoading; - submitBtn.setAttribute("aria-busy", isLoading); + submitBtn.setAttribute("aria-busy", isLoading ? "true" : "false"); btnLabel.style.display = isLoading ? "none" : "inline"; btnLoading.style.display = isLoading ? "inline-flex" : "none"; - if (isLoading) { - resultsGrid.innerHTML = ""; + resultsSection.style.display = "block"; + resultsLoadingEl.style.display = "block"; resultsGrid.style.display = "none"; resultsEmptyEl.style.display = "none"; - resultsEmptyEl.textContent = ""; - resultsLoadingEl.style.display = "block"; - resultsSection.style.display = "block"; resultsSection.scrollIntoView({ behavior: "smooth" }); } else { - resultsLoadingEl.style.display = "none"; - resultsGrid.style.display = "grid"; //switch back to gird layout + resultsLoadingEl.style.display = "none"; } } - // ---------------------------------------------------------- // Render result cards // ---------------------------------------------------------- - //takes the array of projects from the api and draws them on the page as cards - //if array is empty it shows the "no results" message instead - function renderResults(projects, message) { - resultsSection.style.display = "block"; - resultsLoadingEl.style.display = "none"; - // Clear out any cards from a previous search before showing new ones - resultsGrid.innerHTML = ""; - - if (!projects || projects.length === 0) { // if no projects returned from api, show "no results" and hide the grid - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "block"; - if (message && emptyMessageEl) emptyMessageEl.textContent = message; - resultsSection.scrollIntoView({ behavior: "smooth" }); - return; - } - - resultsEmptyEl.style.display = "none"; - resultsGrid.style.display = "grid"; - - //build a card for each project and add it to the grid - projects.forEach(function (project) { - resultsGrid.appendChild(buildProjectCard(project)); - }); - - resultsSection.scrollIntoView({ behavior: "smooth" }); - } - function truncate(text, maxLength) { - text = text || ""; + if (!text) return ""; return text.length > maxLength ? text.slice(0, maxLength) + "..." : text; } function createTag(text, type) { var span = document.createElement("span"); - span.className = "project-tag project-tag--" + normalize(type).replace(/[^a-z0-9_-]/g, "-"); + span.className = "project-tag project-tag--" + type; span.textContent = text; return span; } - //takes the array of projects from the api and draws them on the page as cards - //if array is empty it shows the "no results" message instead + // Renders project result cards or shows the empty-state message. function renderResults(projects, message) { - console.log("Rendering results with projects:", projects); - console.log("Message:", message); - resultsSection.style.display = "block"; resultsLoadingEl.style.display = "none"; - // Clear out any cards from a previous search before showing new ones resultsGrid.innerHTML = ""; - recordSearch(); - if (!projects || projects.length === 0) { - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "block"; - - // Show a friendly custom message when the user selected an interest - var selectedInterest = document.getElementById("interest")?.value; - if (selectedInterest) { - emptyMessageEl.textContent = "No projects are currently available for this interest. Please check back later or try a different area."; - } else if (message) { - emptyMessageEl.textContent = message; - } else { - emptyMessageEl.textContent = "Try adjusting your skills or choosing a different interest area."; - } + var shareWrap = document.getElementById("share-result-wrap"); + var hasResults = projects && projects.length > 0; - // Clear out previous results before rendering new ones - resultsGrid.innerHTML = ""; + // Single consolidated toggle for empty vs. populated state + resultsGrid.style.display = hasResults ? "grid" : "none"; + resultsEmptyEl.style.display = hasResults ? "none" : "block"; + if (shareWrap) shareWrap.style.display = hasResults ? "flex" : "none"; - // If no projects are returned, show the empty state message - if (!projects || projects.length === 0) { - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "block"; + if (!hasResults) { + if (emptyMessageEl) { if (message) emptyMessageEl.textContent = message; } + resultsSection.scrollIntoView({ behavior: "smooth" }); + return; + } + // Build a card for each project and add it to the grid projects.forEach(function (project) { resultsGrid.appendChild(buildProjectCard(project)); }); - recordSearch(); resultsSection.scrollIntoView({ behavior: "smooth" }); } function buildProjectCard(project) { - var card = document.createElement("div"); card.className = "project-card"; - // Console logging for debugging - console.log("Building card for project:", project); - console.log("Project ID:", project.id); - - // Title var title = document.createElement("h3"); title.className = "project-card-title"; title.textContent = project.title; - // Description (truncated for visual consistency) var desc = document.createElement("p"); desc.className = "project-card-desc"; - // Cut description to 120 chars so all cards stay the same height - desc.textContent = truncate(project.description, 120); - - // Tags row - var tagsRow = document.createElement("div"); - tagsRow.className = "project-card-tags"; - - // Show all project skills as tags so users can see the full match - (project.skills || []).forEach(function (skill) { - tagsRow.appendChild(createTag(skill, "skill")); - }); - - // Level tag (colour-coded via CSS class) - // Lowercase so it matches the CSS class names like "level beginner", "level advanced" - tagsRow.appendChild(createTag(project.level, "level " + (project.level || "").toLowerCase())); + var descText = document.createElement("span"); + descText.className = "project-card-desc-text"; + descText.textContent = truncate(project.description, 120); + desc.appendChild(descText); + + if (project.description && project.description.length > 120) { + var expanded = false; + var readMore = document.createElement("button"); + readMore.type = "button"; + readMore.className = "read-more-btn"; + readMore.textContent = "Read more"; + readMore.setAttribute("aria-expanded", "false"); + readMore.addEventListener("click", function () { + expanded = !expanded; + descText.textContent = expanded ? project.description : truncate(project.description, 120); + readMore.textContent = expanded ? "Read less" : "Read more"; + readMore.setAttribute("aria-expanded", expanded ? "true" : "false"); + }); + desc.appendChild(readMore); + } - // Time tag - tagsRow.appendChild(createTag("Time: " + project.time, "time")); + var tags = document.createElement("div"); + tags.className = "project-card-tags"; + (project.skills || []).forEach(function (skill) { tags.appendChild(createTag(skill, "skill")); }); + tags.appendChild(createTag(project.level, project.level)); + tags.appendChild(createTag("Time: " + project.time, "time")); - // Footer with view-details link var footer = document.createElement("div"); footer.className = "project-card-footer"; + if (typeof DevPathBookmarks !== "undefined") { + var saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "btn-save-project"; + saveBtn.setAttribute("data-save-project-id", project.id); + var isSaved = DevPathBookmarks.isSaved(project.id); + if (isSaved) saveBtn.classList.add("saved"); + saveBtn.setAttribute("aria-pressed", isSaved ? "true" : "false"); + DevPathBookmarks.setButtonContent(saveBtn, isSaved); + saveBtn.addEventListener("click", function () { + DevPathBookmarks.toggle(project, saveBtn); + }); + footer.appendChild(saveBtn); + } + var link = document.createElement("a"); link.className = "btn-details"; link.textContent = "View Full Project"; - link.href = "/project/" + project.id; //each project has a unique id - - console.log("Created link with href:", link.href); - link.href = "/project/" + project.id; - footer.appendChild(saveButton); footer.appendChild(link); - // Assemble the card in order card.appendChild(title); card.appendChild(desc); - card.appendChild(tagsRow); + card.appendChild(tags); card.appendChild(footer); return card; } - function runProjectSearch(query) { - if (!query) return; - setLoadingState(true); - fetch("/api/search?q=" + encodeURIComponent(query)) - .then(function (response) { - return response.json().then(function (data) { - if (!response.ok) throw new Error("Search failed. Please try again."); - return data; + + // ---------------------------------------------------------- + // Share My Result ΓÇö build URL and copy to clipboard + // ---------------------------------------------------------- + + var MAX_SHARE_SKILLS = 10; + var MAX_URL_LENGTH = 2000; + + // Build a shareable URL from the current form selections. + // Caps skill count and enforces a max URL length to avoid oversized links. + function buildShareUrl() { + var baseUrl = window.location.origin + window.location.pathname; + var params = new URLSearchParams(); + var allSkills = skillsHidden.value.trim(); + var skillsArr = []; + var truncatedFlag = false; + + if (allSkills) { + skillsArr = allSkills.split(",").map(function (s) { return s.trim(); }).filter(Boolean); + if (skillsArr.length > MAX_SHARE_SKILLS) { + skillsArr = skillsArr.slice(0, MAX_SHARE_SKILLS); + truncatedFlag = true; + } + params.set("skills", skillsArr.join(", ")); + } + + params.set("level", document.getElementById("level").value); + params.set("interest", document.getElementById("interest").value); + params.set("time", document.getElementById("time").value); + + var url = baseUrl + "?" + params.toString(); + + // Progressively trim skills if URL still exceeds safe browser limit + while (url.length > MAX_URL_LENGTH && skillsArr.length > 1) { + skillsArr.pop(); + truncatedFlag = true; + params.set("skills", skillsArr.join(", ")); + url = baseUrl + "?" + params.toString(); + } + + return { url: url, truncated: truncatedFlag }; + } + + var shareBtn = document.getElementById("share-result-btn"); + var shareToast = document.getElementById("share-toast"); + var shareToastTimeout = null; + var _shareWasTruncated = false; + + // Show the "Copied!" state on the share button and display the toast. + function showShareSuccess() { + if (!shareBtn) return; + var originalLabel = shareBtn.querySelector(".share-btn-label"); + var labelText = _shareWasTruncated ? "Copied! (some skills trimmed)" : "Copied!"; + if (originalLabel) originalLabel.textContent = labelText; + shareBtn.classList.add("copied"); + + if (shareToast) shareToast.classList.add("show"); + + // Auto-reset after 2.5 seconds + clearTimeout(shareToastTimeout); + shareToastTimeout = setTimeout(function () { + if (originalLabel) originalLabel.textContent = "Share My Result"; + shareBtn.classList.remove("copied"); + if (shareToast) shareToast.classList.remove("show"); + }, 2500); + } + + // Fallback clipboard copy using a hidden textarea (for older browsers) + function fallbackShareCopy(text) { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand("copy"); showShareSuccess(); } catch (e) { /* silent fail */ } + document.body.removeChild(ta); + } + + if (shareBtn) { + shareBtn.addEventListener("click", function () { + var result = buildShareUrl(); + var url = result.url; + _shareWasTruncated = result.truncated; + + // Use Clipboard API with textarea fallback + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(function () { + showShareSuccess(); + }).catch(function () { + fallbackShareCopy(url); }); - }) - .then(function (projects) { - setLoadingState(false); - recordSearch(); - var message = projects.length - ? null - : "No projects matched \"" + query + "\". Try a different keyword."; - renderResults(projects, message); - var mobileMenu = document.getElementById("nav-mobile-menu"); - var mobileToggle = document.getElementById("nav-mobile-toggle"); - if (mobileMenu && mobileMenu.classList.contains("open")) { - mobileMenu.classList.remove("open"); - if (mobileToggle) { - mobileToggle.classList.remove("open"); - mobileToggle.setAttribute("aria-expanded", "false"); - } - } - }) - .catch(function (err) { - setLoadingState(false); - var general = document.getElementById("form-error-general"); - if (general) general.textContent = err.message || "Search failed. Please try again."; - }); + } else { + fallbackShareCopy(url); + } + }); } - return card; + + // ---------------------------------------------------------- + // Query param validation for shared URLs + // ---------------------------------------------------------- + + var VALID_LEVELS = ["Beginner", "Intermediate", "Advanced"]; + var VALID_INTERESTS = ["Web", "Data", "Education", "Automation", "Games"]; + var VALID_TIMES = ["Low", "Medium", "High"]; + + // Strip HTML tags and restrict to safe characters for skill values + function sanitizeSkillValue(raw) { + if (!raw || typeof raw !== "string") return ""; + // Remove any HTML/script tags + var cleaned = raw.replace(/<[^>]*>/g, ""); + // Allow only safe characters: letters, digits, spaces, dots, #, +, _, -, / + cleaned = cleaned.replace(/[^A-Za-z0-9 .#+_\-\/]/g, ""); + return cleaned.trim(); + } + + // Return the value only if it appears in the allowlist, otherwise "" + function validateDropdownValue(value, allowlist) { + if (!value || typeof value !== "string") return ""; + var trimmed = value.trim(); + for (var i = 0; i < allowlist.length; i++) { + if (allowlist[i] === trimmed) return trimmed; + } + return ""; } - bindSearchForm(document.getElementById("topic-search-form"), document.getElementById("topic-search")); - bindSearchForm(document.getElementById("topic-search-form-mobile"), document.getElementById("topic-search-mobile")); + // ---------------------------------------------------------- + // Auto-fill from shared URL query params (no auto-submit) + // ---------------------------------------------------------- - skillsInput.setAttribute("role", "combobox"); - skillsInput.setAttribute("aria-expanded", "false"); - suggestions.setAttribute("role", "listbox"); + // Pre-fill form from URL params but require user to click Generate + (function initFromQueryParams() { + var params = new URLSearchParams(window.location.search); + var qSkills = params.get("skills"); + var qLevel = params.get("level"); + var qInterest = params.get("interest"); + var qTime = params.get("time"); + + // Only auto-fill if all four params are present + if (!qSkills || !qLevel || !qInterest || !qTime) return; + + // Validate dropdown values against their allowlists + var safeLevel = validateDropdownValue(qLevel, VALID_LEVELS); + var safeInterest = validateDropdownValue(qInterest, VALID_INTERESTS); + var safeTime = validateDropdownValue(qTime, VALID_TIMES); + + // Abort if any dropdown value is invalid + if (!safeLevel || !safeInterest || !safeTime) return; + + // Sanitize and add each skill from the comma-separated query param + qSkills.split(",").forEach(function (s) { + var safe = sanitizeSkillValue(s); + if (safe) window.addSkill(safe); + }); + + // Set dropdown values to the validated selections + document.getElementById("level").value = safeLevel; + document.getElementById("interest").value = safeInterest; + document.getElementById("time").value = safeTime; + + // Show the prefill banner instead of auto-submitting + var banner = document.getElementById("share-prefill-banner"); + var bannerClose = document.getElementById("share-prefill-banner-close"); + if (banner) { + banner.style.display = "flex"; + if (bannerClose) { + bannerClose.addEventListener("click", function () { + banner.style.display = "none"; + }); + } + // Scroll form into view so user sees the pre-filled state + var formSection = document.getElementById("find-project"); + if (formSection) formSection.scrollIntoView({ behavior: "smooth" }); + } + })(); + + + // ---------------------------------------------------------- + // Skill input event listeners + // ---------------------------------------------------------- skillsInput.addEventListener("input", function () { showSuggestions(filteredSkills(skillsInput.value)); @@ -947,7 +798,6 @@ updateProfileWidgets(); renderSuggestionState(); return; } - if (event.key === "Escape") { hideSuggestions(); return; @@ -978,261 +828,333 @@ updateProfileWidgets(); skillWrap.addEventListener("click", function () { skillsInput.focus(); }); } - function truncate(text, maxLength) { - // Safety check — just return empty string if text is missing - if (!text) return ""; - // Only add "..." if the text is actually longer than the limit - return text.length > maxLength ? text.slice(0, maxLength) + "..." : text; + var clearBtn = document.getElementById("clear-filters-btn"); + if (clearBtn) { + clearBtn.addEventListener("click", function () { + form.reset(); + selectedSkills = []; + renderSelectedChips(); + syncSkillsHiddenInput(); + updateQuickPickState(); + clearAllErrors(); + hideSuggestions(); + resultsSection.style.display = "none"; + skillsInput.focus(); + }); + } + + var resetProgressBtn = document.getElementById("reset-progress-btn"); + if (resetProgressBtn) { + resetProgressBtn.addEventListener("click", function () { + progress.searches = 0; + progress.projectViews = 0; + progress.codeOpens = 0; + progress.completions = 0; + progress.points = 0; + progress.viewedProjects = []; + progress.completedProjects = []; + progress.achievements = []; + progress.badges = { + first_search: false, + project_explorer: false, + code_starter: false, + completionist: false, + roadmap_runner: false + }; + saveProgressState(); + updateProfileWidgets(); + showAchievementToast("Progress reset", "Your local profile has been cleared."); + }); + } + + // ---------------------------------------------------------- + // Form submission and API call + // ---------------------------------------------------------- + + form.addEventListener("submit", function (event) { + event.preventDefault(); + clearAllErrors(); + if (skillsInput.value.trim()) { + window.addSkill(skillsInput.value); + skillsInput.value = ""; + hideSuggestions(); + } + if (!validateForm()) return; + setLoadingState(true); + fetch("/api/recommend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + skills: JSON.stringify(selectedSkills), + level: document.getElementById("level").value, + interest: document.getElementById("interest").value, + time: document.getElementById("time").value + }) + }) + .then(function (response) { + return response.json().then(function (data) { + if (!response.ok) throw new Error(data.error || "Unable to generate recommendations."); + return data; + }); + }) + .then(function (data) { + setLoadingState(false); + recordSearch(); + renderResults(data.projects || [], data.message); + }) + .catch(function (err) { + setLoadingState(false); + var general = document.getElementById("form-error-general"); + if (general) general.textContent = err.message || "An unexpected error occurred. Please try again."; + }); + }); + + // ---------------------------------------------------------- + // GitHub modal + // ---------------------------------------------------------- + + var modal = document.getElementById("github-modal-overlay"); + var openModalBtn = document.getElementById("btn-show-github"); + var closeModalBtn = document.getElementById("btn-close-github"); + var fetchBtn = document.getElementById("btn-fetch-github"); + var githubInput = document.getElementById("github-username"); + var errorMsg = document.getElementById("github-modal-error"); + + function closeGithubModal() { + modal.classList.remove("active"); + githubInput.value = ""; + errorMsg.textContent = ""; } -} // end isIndexPage + if (modal && openModalBtn && closeModalBtn && fetchBtn && githubInput && errorMsg) { + openModalBtn.addEventListener("click", function () { + modal.classList.add("active"); + githubInput.focus(); + }); + closeModalBtn.addEventListener("click", closeGithubModal); + modal.addEventListener("click", function (event) { + if (event.target === modal) closeGithubModal(); + }); + fetchBtn.addEventListener("click", function (event) { + event.preventDefault(); + var username = githubInput.value.trim(); + errorMsg.textContent = ""; + if (!username) { + errorMsg.textContent = "Please enter a GitHub username."; + return; + } + fetchBtn.disabled = true; + fetchBtn.textContent = "Syncing..."; + fetch("https://api.github.com/users/" + encodeURIComponent(username) + "/repos?sort=updated&per_page=100") + .then(function (response) { + if (!response.ok) throw new Error(response.status === 404 ? "Username not found." : "Unable to fetch GitHub repositories."); + return response.json(); + }) + .then(function (repos) { + var languages = []; + repos.forEach(function (repo) { + if (repo.language && languages.indexOf(repo.language) === -1) languages.push(repo.language); + }); + if (!languages.length) { + errorMsg.textContent = "No public languages found."; + return; + } + languages.forEach(window.addSkill); + closeGithubModal(); + }) + .catch(function (err) { + if (err.message && err.message.toLowerCase().indexOf("networkerror") !== -1 || err.name === "TypeError") { + errorMsg.textContent = "Network error: Connection blocked or offline. Please disable adblockers or check your connection."; + } else { + errorMsg.textContent = err.message || "Failed to fetch skills."; + } + fetchBtn.disabled = false; + fetchBtn.textContent = "Fetch Skills"; + }); + }); + } +})(); // ============================================================ // DETAIL PAGE // ============================================================ -if (isDetailPage) { +(function initDetailPage() { + if (typeof PROJECT_ID === "undefined") return; + recordProjectView(); + + var codePanel = document.getElementById("code-panel"); + var codePanelOverlay = document.getElementById("code-panel-overlay"); + var codeContentEl = document.getElementById("code-content"); + var codePanelFilename = document.getElementById("code-panel-filename"); + var btnViewCode = document.getElementById("btn-view-code"); + var btnViewCodeSm = document.getElementById("btn-view-code-sm"); + var btnClosePanel = document.getElementById("code-panel-close"); + var btnCopyCode = document.getElementById("btn-copy-code"); + var copyToast = document.getElementById("copy-toast"); + var completionBtn = document.getElementById("btn-mark-complete"); + var codeFetched = false; - var codePanel = document.getElementById("code-panel"); // sliding panel that shows the starter code " - var codePanelOverlay = document.getElementById("code-panel-overlay"); // background overlay - var codeContentEl = document.getElementById("code-content"); //
     element inside the panel where the code will be inserted
    -  var codePanelFilename = document.getElementById("code-panel-filename"); // filename display
    -  var btnViewCode       = document.getElementById("btn-view-code"); // button to open the code panel on desktop
    -  var btnViewCodeSm     = document.getElementById("btn-view-code-sm"); // button to open the code panel on mobile (could be the same button with different styling, but we have two here for simplicity)
    -  var btnClosePanel     = document.getElementById("code-panel-close"); // button inside the panel to close it
    +  function renderCode(code) {
    +    codeContentEl.textContent = "";
    +    String(code || "").split("\n").forEach(function (line, index) {
    +      var row = document.createElement("div");
    +      row.className = "code-line";
    +      var number = document.createElement("span");
    +      number.className = "code-line-number";
    +      number.setAttribute("aria-hidden", "true");
    +      number.textContent = index + 1;
    +      var content = document.createElement("span");
    +      content.className = "code-line-content";
    +      content.textContent = line;
    +      row.appendChild(number);
    +      row.appendChild(content);
    +      codeContentEl.appendChild(row);
    +    });
    +  }
     
    -  // Cache flag so code is only fetched once per page load
    -  var codeFetched = false;
    +  function fetchStarterCode() {
    +    codeContentEl.textContent = "Loading starter code...";
    +    fetch("/project/" + PROJECT_ID + "/code")
    +      .then(function (response) {
    +        return response.json().then(function (data) {
    +          if (!response.ok) throw new Error(data.error || "Starter code unavailable.");
    +          return data;
    +        });
    +      })
    +      .then(function (data) {
    +        codePanelFilename.textContent = data.filename;
    +        renderCode(data.code);
    +        codeFetched = true;
    +      })
    +      .catch(function (err) {
    +        codeContentEl.textContent = err.message || "Could not load starter code. Try downloading it instead.";
    +      });
    +  }
     
    -  //opens the sliding code panel 
       function openCodePanel() {
    -    // Panel element might not exist on every detail page, so check first
         if (!codePanel) return;
         codePanel.classList.add("active");
         if (codePanelOverlay) codePanelOverlay.classList.add("active");
    -    // Lock background scroll so the page doesn't scroll behind the panel
         document.body.style.overflow = "hidden";
    -
    -    // Only fetch the code on the first open, no need to re-fetch every time
    +    recordCodeOpen();
         if (!codeFetched) fetchStarterCode();
       }
     
    -  //closes the code panel and hides the overlay
       function closeCodePanel() {
         if (!codePanel) return;
         codePanel.classList.remove("active");
         if (codePanelOverlay) codePanelOverlay.classList.remove("active");
    -    // Restore normal scrolling once the panel is closed
         document.body.style.overflow = "";
       }
     
    -  //fetches the starter code from the server via an API call
    -  //inserts the code into the panel and handles loading/error states
    -  function fetchStarterCode() {
    -    // Show a loading message while we wait for the API response
    -    if (codeContentEl) codeContentEl.textContent = "Loading starter code...";
    -
    -    fetch("/project/" + PROJECT_ID + "/code")
    -      .then(function (res) { return res.json(); })
    -      .then(function (data) {
    -        if (data.error) {
    -          if (codeContentEl) codeContentEl.textContent = "Error: " + data.error;
    -          return;
    -        }
    -        if (codePanelFilename) codePanelFilename.textContent = data.filename;
    -        if (codeContentEl) {
    -          codeContentEl.textContent = "";
    -          renderCodeWithLineNumbers(data.code).forEach(function (row) {
    -            codeContentEl.appendChild(row);
    -          });
    -        }
    -        // Mark as fetched so we don't hit the API again on the next open
    -        codeFetched = true;
    -      })
    -      .catch(function () {
    -        if (codeContentEl) {
    -          codeContentEl.textContent = "Could not load starter code. Try downloading it instead.";
    -        }
    -      });
    -  }
    -
    -  // Attach open/close handlers
       if (btnViewCode) btnViewCode.addEventListener("click", openCodePanel);
       if (btnViewCodeSm) btnViewCodeSm.addEventListener("click", openCodePanel);
       if (btnClosePanel) btnClosePanel.addEventListener("click", closeCodePanel);
    -
    -  if (codePanelOverlay) {
    -    codePanelOverlay.addEventListener("click", closeCodePanel); //clicking on the background overlay to also close the panel
    -  }
    -
    -  // Let keyboard users close the panel with Escape — important for accessibility
    -  document.addEventListener("keydown", function (evt) {
    -    if (evt.key === "Escape") closeCodePanel(); //esc key to close
    +  if (codePanelOverlay) codePanelOverlay.addEventListener("click", closeCodePanel);
    +  document.addEventListener("keydown", function (event) {
    +    if (event.key === "Escape") closeCodePanel();
       });
     
    -  // ----------------------------------------------------------
    -  // Copy Code button
    -  // ----------------------------------------------------------
    -  var btnCopyCode  = document.getElementById("btn-copy-code");
    -  var copyToast    = document.getElementById("copy-toast"); //popup msg when copied 
    -  var toastTimeout = null; 
    -
    -  //shows the "copied to clipboard" state on the button and the toast message, then resets after a short delay
    -  function showCopySuccess() {
    -    if (!btnCopyCode) return;
    -
    -    // Swap icons on the button(copy and checkmark icons)
    -    var copyIcon  = btnCopyCode.querySelector(".copy-icon");
    -    var checkIcon = btnCopyCode.querySelector(".check-icon");
    -    var btnLabel  = btnCopyCode.querySelector(".copy-btn-label");
    -    if (copyIcon)  copyIcon.style.display  = "none";
    -    if (checkIcon) checkIcon.style.display = "inline";
    -    if (btnLabel) btnLabel.textContent = "Copied!";
    -    btnCopyCode.classList.add("copied");
    -    // Disable button so user can't spam click it while toast is showing
    -    btnCopyCode.disabled = true;
    -
    -    // Show toast
    -    if (copyToast) {
    -      copyToast.classList.add("show");
    -    }
    -
    -    // Auto-reset after 2.5 s
    -    // Clear any previous timeout first so timers don't stack up
    -    clearTimeout(toastTimeout);
    -    toastTimeout = setTimeout(function () {
    -      if (copyIcon) copyIcon.style.display = "inline";
    -      if (checkIcon) checkIcon.style.display = "none";
    -      if (btnLabel) btnLabel.textContent = "Copy Code";
    -      btnCopyCode.classList.remove("copied");
    -      btnCopyCode.disabled = false;
    -      if (copyToast) copyToast.classList.remove("show");
    -    }, 2500);
    -  }
    -
       if (btnCopyCode) {
         btnCopyCode.addEventListener("click", function () {
    -      var code = codeContentEl
    -        ? Array.from(codeContentEl.querySelectorAll(".line-content"))
    -          .map(function (el) { return el.textContent; })
    -          .join("\n")
    -        : "";
    -      // Don't copy if the code hasn't loaded yet — just ignore the click
    -      if (!code || code === "Loading..." || code === "Loading starter code...") return;
    -
    -      // Use Clipboard API with textarea fallback
    +      var code = Array.prototype.slice.call(codeContentEl.querySelectorAll(".code-line-content"))
    +        .map(function (line) { return line.textContent; })
    +        .join("\n");
    +      if (!code) return;
    +      var done = function () {
    +        if (copyToast) {
    +          copyToast.classList.add("show");
    +          window.setTimeout(function () { copyToast.classList.remove("show"); }, 2500);
    +        }
    +      };
           if (navigator.clipboard && navigator.clipboard.writeText) {
    -        navigator.clipboard.writeText(code).then(showCopySuccess).catch(function () {
    -          fallbackCopy(code); // clipboard api failed, try the old way
    -        });
    +        navigator.clipboard.writeText(code).then(done);
           } else {
    -        fallbackCopy(code); // Clipboard API not supported, use fallback method
    +        var textarea = document.createElement("textarea");
    +        textarea.value = code;
    +        textarea.style.cssText = "position:fixed;top:-9999px;left:-9999px";
    +        document.body.appendChild(textarea);
    +        textarea.focus();
    +        textarea.select();
    +        try { document.execCommand("copy"); } catch (err) {}
    +        document.body.removeChild(textarea);
    +        done();
           }
         });
    -  } // end github modal handlers
    -
    -    /* ---- Scroll-to-top button ---- */
    -      
    -  var SCROLL_THRESHOLD = 300;
    -  var scrollTopBtn = document.getElementById('scroll-top-btn');
    -
    -  function handleScroll() {
    -    if (!scrollTopBtn) return;
    -    if (window.pageYOffset > SCROLL_THRESHOLD) {
    -      scrollTopBtn.classList.add('visible');
    -    } else {
    -      scrollTopBtn.classList.remove('visible');
    -    }
    -  }
    -
    -  function scrollToTop() {
    -    window.scrollTo({ top: 0, behavior: 'smooth' });
       }
     
    -  if (scrollTopBtn) {
    -    window.addEventListener('scroll', handleScroll);
    -    scrollTopBtn.addEventListener('click', scrollToTop);
    -  }
    +  var roadmapCheckboxes = Array.prototype.slice.call(document.querySelectorAll(".roadmap-checkbox"));
    +  var progressFill = document.getElementById("roadmap-progress-fill");
    +  var progressText = document.getElementById("roadmap-progress-text");
    +  var progressBar = document.querySelector(".roadmap-progress-bar");
    +  var roadmapStorageKey = "devpath-roadmap-progress-" + PROJECT_ID;
    +
    +  function updateRoadmapProgress() {
    +    if (!roadmapCheckboxes.length) return;
    +    var completed = roadmapCheckboxes.filter(function (checkbox) { return checkbox.checked; }).length;
    +    var percent = Math.round((completed / roadmapCheckboxes.length) * 100);
    +    roadmapCheckboxes.forEach(function (checkbox) {
    +      var step = checkbox.closest(".roadmap-step");
    +      if (step) step.classList.toggle("completed", checkbox.checked);
    +    });
    +    if (progressFill) progressFill.style.width = percent + "%";
    +    if (progressText) progressText.textContent = percent + "% completed";
    +    if (progressBar) progressBar.setAttribute("aria-valuenow", String(percent));
    +    try {
    +      localStorage.setItem(roadmapStorageKey, JSON.stringify(roadmapCheckboxes.map(function (checkbox) {
    +        return checkbox.checked;
    +      })));
    +    } catch (err) {}
       }
     
    -  // Fallback method to copy text using a hidden textarea and execCommand (for older browsers)
    -  function fallbackCopy(text) {
    -    // Some older browsers don't support navigator.clipboard, so we use a hidden textarea instead
    -    var ta = document.createElement("textarea");
    -    ta.value = text;
    -    // Push it off-screen so it's not visible but can still be selected
    -    ta.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0";
    -    document.body.appendChild(ta);
    -    ta.focus();
    -    ta.select();
    -    // execCommand is old and deprecated but works as a last resort — fail silently if it doesn't
    -    try { document.execCommand("copy"); showCopySuccess(); } catch (e) { /* silent fail */ }
    -    document.body.removeChild(ta);
    -  }
    -} // end isDetailPage
    -
    -if (
    -    openModalBtn &&
    -    closeModalBtn &&
    -    modal &&
    -    githubInput &&
    -    fetchBtn &&
    -    errorMsg
    -) {
    -// 1. Open Github Input Modal
    -  openModalBtn.addEventListener('click', (e) => {
    -      e.preventDefault();
    -      modal.classList.add('active');
    -      githubInput.focus();
    +  try {
    +    var saved = JSON.parse(localStorage.getItem(roadmapStorageKey) || "[]");
    +    roadmapCheckboxes.forEach(function (checkbox, index) {
    +      checkbox.checked = !!saved[index];
    +    });
    +  } catch (err) {}
    +  roadmapCheckboxes.forEach(function (checkbox) {
    +    checkbox.addEventListener("change", updateRoadmapProgress);
       });
    +  updateRoadmapProgress();
     
    -  // 2. Close Github Input Modal
    -  const closeGithubModal = () => {
    -      modal.classList.remove('active');
    -      githubInput.value = '';
    -      errorMsg.textContent = '';
    -  };
    +  if (completionBtn) {
    +    completionBtn.addEventListener("click", function () {
    +      recordCompletion(PROJECT_ID, typeof PROJECT_TITLE !== "undefined" ? PROJECT_TITLE : "");
    +      showAchievementToast("Project completed", "Nice work finishing this project.");
    +    });
    +  }
    +})();
     
    -  closeModalBtn.addEventListener('click', closeGithubModal);
     
    -  // Close on clicking outside the card
    -  modal.addEventListener('click', (e) => {
    -      if (e.target === modal) closeGithubModal();
    -  });
    +// ============================================================
    +// Scroll-to-top / scroll-to-bottom button
    +// ============================================================
    +(function initScrollButton() {
    +  var button = document.getElementById("scroll-top-btn");
    +  var icon = document.getElementById("scroll-btn-icon");
    +  if (!button) return;
    +  var atBottom = false;
    +
    +  function nearBottom() {
    +    return window.innerHeight + window.pageYOffset >= document.body.scrollHeight - 40;
    +  }
     
    -  // 3. Fetch Skills Logic
    -  fetchBtn.addEventListener('click', async () => {
    -      const username = githubInput.value.trim();
    -      if (!username) return;
    +  function update() {
    +    button.classList.toggle("visible", window.pageYOffset > 200);
    +    atBottom = nearBottom();
    +    button.setAttribute("aria-label", atBottom ? "Scroll to top" : "Scroll to bottom");
    +    button.title = atBottom ? "Scroll to top" : "Scroll to bottom";
    +    if (icon) icon.innerHTML = atBottom ? '' : '';
    +  }
     
    -      fetchBtn.disabled = true;
    -      fetchBtn.textContent = 'Syncing...';
    -
    -      try {
    -          const response = await fetch(`https://api.github.com/users/${username}/repos`);
    -          if (!response.ok) throw new Error();
    -          
    -          const repos = await response.json();
    -          const langs = [...new Set(repos.map(r => r.language).filter(Boolean))];
    -
    -          if (langs.length > 0) {
    -              langs.forEach(lang => {
    -                  if (typeof addSkill === 'function') addSkill(lang);
    -              });
    -              closeGithubModal();
    -          } else {
    -              errorMsg.textContent = "No public languages found.";
    -          }
    -      } catch (err) {
    -          errorMsg.textContent = err.message ?? "Failed to fetch skills";
    -      } finally {
    -          fetchBtn.disabled = false;
    -          fetchBtn.textContent = 'Fetch Skills';
    -      }
    +  window.addEventListener("scroll", update, { passive: true });
    +  button.addEventListener("click", function () {
    +    window.scrollTo({ top: atBottom ? 0 : document.body.scrollHeight, behavior: "smooth" });
       });
       update();
     })();
    -
     (function initScrollSpy() {
       var sections = document.querySelectorAll("section[id], header[id]");
       var navLinks = document.querySelectorAll(".nav-link, .nav-mobile-link");
    @@ -1241,22 +1163,19 @@ if (
     
       var observerOptions = {
         root: null,
    -    rootMargin: "-20% 0px -70% 0px",
    +    rootMargin: "0px 0px -50% 0px",
         threshold: 0
       };
     
    -  if (scrollTopBtn) {
    -    window.addEventListener('scroll', handleScroll, { passive: true });
    -    scrollTopBtn.addEventListener('click', function () {
    -      if (atBottom) {
    -        window.scrollTo({ top: 0, behavior: 'smooth' });
    -      } else {
    -        window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
    +  var observer = new IntersectionObserver(function(entries) {
    +    entries.forEach(function(entry) {
    +      if (entry.isIntersecting) {
    +        navLinks.forEach(function(link) {
    +          link.classList.toggle('active', link.getAttribute('href') === '#' + entry.target.id);
    +        });
           }
         });
    -    handleScroll();
    -  }
    -}());
    +  }, observerOptions);
     
       sections.forEach(function (sec) {
         observer.observe(sec);
    diff --git a/static/style.css b/static/style.css
    index 1cbf860..b578f91 100644
    --- a/static/style.css
    +++ b/static/style.css
    @@ -5022,3 +5022,123 @@ body.dark-theme .theme-toggle:hover {
       background: rgba(16, 185, 129, 0.15);
       color: #6ee7b7;
     }
    +
    +
    +/* ---- Share Result button ---- */
    +.share-result-wrap {
    +  display: flex;
    +  justify-content: flex-end;
    +  margin-bottom: 1rem;
    +}
    +
    +.btn-share {
    +  display: inline-flex;
    +  align-items: center;
    +  gap: 8px;
    +  padding: 10px 20px;
    +  border: 1px solid var(--border);
    +  border-radius: var(--r-md);
    +  background: var(--white);
    +  color: var(--text-body);
    +  font-family: var(--font-body);
    +  font-size: 0.875rem;
    +  font-weight: 500;
    +  cursor: pointer;
    +  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
    +}
    +
    +.btn-share:hover {
    +  background: var(--indigo-600);
    +  color: #fff;
    +  border-color: var(--indigo-600);
    +  transform: translateY(-1px);
    +}
    +
    +.btn-share.copied {
    +  background: var(--green-500);
    +  color: #fff;
    +  border-color: var(--green-500);
    +}
    +
    +
    +
    +/* ---- Share Toast notification ---- */
    +.share-toast {
    +  position: fixed;
    +  bottom: 2rem;
    +  left: 50%;
    +  transform: translateX(-50%) translateY(20px);
    +  background: var(--gray-900);
    +  color: #fff;
    +  padding: 12px 24px;
    +  border-radius: var(--r-md);
    +  font-family: var(--font-body);
    +  font-size: 0.85rem;
    +  font-weight: 500;
    +  display: flex;
    +  align-items: center;
    +  gap: 8px;
    +  opacity: 0;
    +  pointer-events: none;
    +  transition: opacity 0.3s ease, transform 0.3s ease;
    +  z-index: 1000;
    +  box-shadow: var(--shadow-lg);
    +}
    +
    +.share-toast.show {
    +  opacity: 1;
    +  transform: translateX(-50%) translateY(0);
    +  pointer-events: auto;
    +}
    +
    +
    +
    +/* ---- Share Prefill Banner ---- */
    +.share-prefill-banner {
    +  display: flex;
    +  align-items: flex-start;
    +  justify-content: space-between;
    +  gap: 12px;
    +  padding: 14px 18px;
    +  margin-bottom: 24px;
    +  background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(139, 92, 246, 0.08));
    +  border: 1px solid rgba(99, 102, 241, 0.22);
    +  border-radius: var(--r-sm);
    +  font-size: 0.88rem;
    +  line-height: 1.6;
    +  color: var(--text-body);
    +  animation: bannerSlideIn 0.35s ease-out;
    +}
    +
    +@keyframes bannerSlideIn {
    +  from { opacity: 0; transform: translateY(-8px); }
    +  to   { opacity: 1; transform: translateY(0); }
    +}
    +
    +.share-prefill-banner-content {
    +  display: flex;
    +  align-items: flex-start;
    +  gap: 10px;
    +}
    +
    +.share-prefill-banner-content svg {
    +  flex-shrink: 0;
    +  margin-top: 3px;
    +  color: var(--indigo-600);
    +}
    +
    +.share-prefill-banner-close {
    +  background: none;
    +  border: none;
    +  font-size: 1.25rem;
    +  cursor: pointer;
    +  color: var(--gray-400);
    +  padding: 0 4px;
    +  line-height: 1;
    +  flex-shrink: 0;
    +  transition: color var(--t);
    +}
    +
    +.share-prefill-banner-close:hover {
    +  color: var(--text-heading);
    +}
    diff --git a/templates/index.html b/templates/index.html
    index cf11844..4265d16 100644
    --- a/templates/index.html
    +++ b/templates/index.html
    @@ -8,6 +8,35 @@
         content="DevPath recommends real coding projects based on your skills, level, and interests — with full roadmaps and starter code." />
       DevPath — Find Projects Based On Your Skills
       
    +  
    +
       
       
       
    @@ -42,65 +71,6 @@