diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9ebf2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/routes/main_routes.py b/routes/main_routes.py index db68640..896b447 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -100,7 +100,16 @@ def recommend(): ) }), 200 - return jsonify({"projects": results}), 200 + # Ensure all projects have IDs in the response + projects_data = [] + for project in results: + project_dict = dict(project) # Convert to dict if needed + # Make sure ID is included + if 'id' not in project_dict: + project_dict['id'] = project.get('id', 0) + projects_data.append(project_dict) + + return jsonify({"projects": projects_data}), 200 @main.route("/project/") diff --git a/static/script.js b/static/script.js index 28dbbeb..6a2632a 100644 --- a/static/script.js +++ b/static/script.js @@ -381,6 +381,12 @@ updateProfileWidgets(); 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"); @@ -412,6 +418,23 @@ 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"); @@ -422,6 +445,12 @@ 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(); @@ -442,6 +471,50 @@ 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; @@ -453,6 +526,223 @@ updateProfileWidgets(); 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 + // ---------------------------------------------------------- + + //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 + // ---------------------------------------------------------- + + form.addEventListener("submit", function (evt) { + evt.preventDefault(); //stop the browser from reloading the page on form submit + clearAllErrors() + + if (skillsTextInput.value.trim()) { + addSkill(skillsTextInput.value); + skillsTextInput.value = ""; + hideSuggestions(); + } + + if (!validateForm()) return; //stop - anything missing/invalid + + setLoadingState(true); + + // Allow browser to paint spinner before request starts + requestAnimationFrame(function () { + + var payload = { + skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), + level: document.getElementById("level").value, + interest: document.getElementById("interest").value, + time: document.getElementById("time").value + }; + + fetch("/api/recommend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }) + .then(function (res) { + return res.json(); + }) + .then(function (data) { + console.log("API Response:", data); + setLoadingState(false); + evt.preventDefault(); + + clearAllErrors(); + + if (skillsTextInput.value.trim()) { + addSkill(skillsTextInput.value); + skillsTextInput.value = ""; + hideSuggestions(); + } + + if (!validateForm()) return; + + setLoadingState(true); + + renderResults(data.projects || [], data.message); + }) + .catch(function () { + setLoadingState(false); + var generalErr = document.getElementById("form-error-general"); + if (generalErr) { + generalErr.textContent = "Network error. Please try again."; + } + }); + }); + }); + generalErr.textContent = "An unexpected error occurred. Please try again."; + } + console.error("API request failed:", err); + }); + }); + }); + + // Manages the loading state of the form and results section(whats visible or not) function setLoadingState(isLoading) { submitBtn.disabled = isLoading; submitBtn.setAttribute("aria-busy", isLoading ? "true" : "false"); @@ -481,10 +771,58 @@ updateProfileWidgets(); 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 + 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."; + } + + // Clear out previous results before rendering new ones + resultsGrid.innerHTML = ""; + + // If no projects are returned, show the empty state message + if (!projects || projects.length === 0) { + resultsGrid.style.display = "none"; + resultsEmptyEl.style.display = "block"; + + projects.forEach(function (project) { + resultsGrid.appendChild(buildProjectCard(project)); + }); + + recordSearch(); + resultsSection.scrollIntoView({ behavior: "smooth" }); + return; + } + 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; @@ -523,6 +861,10 @@ updateProfileWidgets(); 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(link); diff --git a/static/style.css b/static/style.css index ae3e9a0..81b95c0 100644 --- a/static/style.css +++ b/static/style.css @@ -1588,6 +1588,83 @@ label { box-shadow: none; } +/* Multi-select wrapper for skills input */ +.skill-multiselect-wrapper { + display: flex; + align-items: center; + flex: 1; + min-width: 140px; + gap: 4px; + position: relative; +} + +.skill-dropdown-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + color: var(--gray-500); + display: flex; + align-items: center; + justify-content: center; + transition: color var(--t); + flex-shrink: 0; +} + +.skill-dropdown-btn:hover { + color: var(--indigo-600); +} + +.skill-dropdown-btn svg { + width: 16px; + height: 16px; +} + +/* Multi-select dropdown styling */ +.skills-dropdown.show { + display: block; + animation: slideDown 0.2s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.skills-dropdown .suggestion-item { + display: flex; + align-items: center; + gap: 8px; +} + +.skills-dropdown .suggestion-item::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + border: 1.5px solid var(--gray-300); + border-radius: 3px; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.skills-dropdown .suggestion-item.selected::before { + background: var(--indigo-600); + border-color: var(--indigo-600); + box-shadow: inset 0 0 0 2px var(--white); +} + +.skills-dropdown .suggestion-item:hover { + background: var(--indigo-50); +} + +/*suggestions dropdown*/ /* Suggestions dropdown */ .skills-suggestions { position: absolute; diff --git a/tempCodeRunnerFile.py b/tempCodeRunnerFile.py new file mode 100644 index 0000000..7a0b7f0 --- /dev/null +++ b/tempCodeRunnerFile.py @@ -0,0 +1 @@ +app \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 5c149c1..4729b48 100644 --- a/templates/index.html +++ b/templates/index.html @@ -71,6 +71,7 @@ Home How It Works Features + Find Project Find Project Contact Us GitHub @@ -186,7 +187,7 @@

@@ -474,7 +475,7 @@

Everything You Need to Start Building

Personalized Matches

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

- Try it now + Try it now
@@ -486,7 +487,7 @@

Personalized Matches

Step-by-Step Roadmaps

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

- Explore roadmaps + Explore roadmaps
@@ -498,6 +499,8 @@

Step-by-Step 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 @@ -510,10 +513,10 @@

Starter Code Included

-
+
Get Your Recommendations
-

Find Your Next Project

+

Find The Project

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

@@ -546,6 +549,21 @@

Find Your Next Project

+
+ + +
+
@@ -752,7 +770,7 @@

No Projects Found

Start Building.
A New Skill Awaits.

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

- Find My Project + Find My Project
@@ -804,6 +822,7 @@
  • Home
  • How It Works
  • Features
  • +
  • Find Project
  • Find Project
  • Contact Us