diff --git a/data/projects.json b/data/projects.json index a0ac29e0..98937e04 100644 --- a/data/projects.json +++ b/data/projects.json @@ -456,7 +456,36 @@ "W3Schools JavaScript: https://www.w3schools.com/js" ], "starter_code": "starter_code/quiz_app.html" - } + }, +{ + "id": 11, + "title": "ProfilePro README Generator", + "skills": ["Python", "Flask", "HTML"], + "level": "Intermediate", + "interest": "Web", + "time": "Medium", + "description": "A tool that generates professional README.md files using a structured form interface for open-source projects.", + "features": [ + "Generate README.md automatically", + "Structured form input", + "Download generated README", + "Standardized documentation sections", + "Markdown formatting automation" + ], + "tech_stack": ["Python", "Flask", "HTML", "Markdown"], + "roadmap": [ + "Step 1: Create README form", + "Step 2: Add project detail fields", + "Step 3: Generate markdown dynamically", + "Step 4: Add download functionality", + "Step 5: Improve formatting and validation" + ], + "resources": [ + "https://www.markdownguide.org/", + "https://flask.palletsprojects.com/" + ], + "starter_code_file": "readme_generator.py" +} ] diff --git a/routes/main_routes.py b/routes/main_routes.py index 658553ef..f58e0e43 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -2,6 +2,7 @@ # All application routes registered as a Flask Blueprint. # Each route is kept thin: it validates input, calls a utility function, # and returns a response. No business logic lives here. +from flask import send_from_directory from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, make_response @@ -157,3 +158,4 @@ def sitemap(): def robots(): """Serve robots.txt from the static folder.""" return send_from_directory("static", "robots.txt", mimetype="text/plain") + diff --git a/starter_code/api_data_pipeline.py b/starter_codes/api_data_pipeline.py similarity index 100% rename from starter_code/api_data_pipeline.py rename to starter_codes/api_data_pipeline.py diff --git a/starter_code/data_report.py b/starter_codes/data_report.py similarity index 100% rename from starter_code/data_report.py rename to starter_codes/data_report.py diff --git a/starter_code/email_automation.py b/starter_codes/email_automation.py similarity index 100% rename from starter_code/email_automation.py rename to starter_codes/email_automation.py diff --git a/starter_code/expense_tracker.py b/starter_codes/expense_tracker.py similarity index 100% rename from starter_code/expense_tracker.py rename to starter_codes/expense_tracker.py diff --git a/starter_code/grade_manager.py b/starter_codes/grade_manager.py similarity index 100% rename from starter_code/grade_manager.py rename to starter_codes/grade_manager.py diff --git a/starter_code/number_guessing.py b/starter_codes/number_guessing.py similarity index 100% rename from starter_code/number_guessing.py rename to starter_codes/number_guessing.py diff --git a/starter_code/portfolio.html b/starter_codes/portfolio.html similarity index 100% rename from starter_code/portfolio.html rename to starter_codes/portfolio.html diff --git a/starter_code/quiz_app.html b/starter_codes/quiz_app.html similarity index 100% rename from starter_code/quiz_app.html rename to starter_codes/quiz_app.html diff --git a/starter_codes/readme_generator.py b/starter_codes/readme_generator.py new file mode 100644 index 00000000..dc3e47ec --- /dev/null +++ b/starter_codes/readme_generator.py @@ -0,0 +1,10 @@ +from flask import Flask + +app = Flask(__name__) + +@app.route("/") +def home(): + return "ProfilePro README Generator" + +if __name__ == "__main__": + app.run(debug=True) \ No newline at end of file diff --git a/starter_code/survey_form/index.html b/starter_codes/survey_form/index.html similarity index 100% rename from starter_code/survey_form/index.html rename to starter_codes/survey_form/index.html diff --git a/starter_code/survey_form/style.css b/starter_codes/survey_form/style.css similarity index 100% rename from starter_code/survey_form/style.css rename to starter_codes/survey_form/style.css diff --git a/starter_code/task_api.py b/starter_codes/task_api.py similarity index 100% rename from starter_code/task_api.py rename to starter_codes/task_api.py diff --git a/starter_code/url_shortener.py b/starter_codes/url_shortener.py similarity index 100% rename from starter_code/url_shortener.py rename to starter_codes/url_shortener.py diff --git a/starter_code/weather_dashboard.html b/starter_codes/weather_dashboard.html similarity index 100% rename from starter_code/weather_dashboard.html rename to starter_codes/weather_dashboard.html diff --git a/static/script.js b/static/script.js index f97e5a0d..2edf6841 100644 --- a/static/script.js +++ b/static/script.js @@ -1,132 +1,90 @@ // 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) - -// ============================================================ -// 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 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"); -// ============================================================ -// Mobile navigation toggle (runs on all pages) -// ============================================================ +// Mobile navigation (function initMobileNav() { - var toggle = document.getElementById("nav-mobile-toggle"); //hamburger button - var menu = document.getElementById("nav-mobile-menu"); //dropdown menu + var toggle = document.getElementById("nav-mobile-toggle"); + var menu = document.getElementById("nav-mobile-menu"); - // Nothing to do if the nav isn't on this page, just bail out 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"); 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); }); - // Close menu when any mobile link is clicked - menu.querySelectorAll(".nav-mobile-link").forEach(function (link) { - link.addEventListener("click", function () { - menu.classList.remove("open"); + menu.querySelectorAll(".nav-mobile-link").forEach(function (link) { + link.addEventListener("click", function () { + menu.classList.remove("open"); toggle.classList.remove("open"); + toggle.setAttribute("aria-expanded", "false"); }); }); })(); - -// ============================================================ // INDEX PAGE -// ============================================================ if (isIndexPage) { + var form = document.getElementById("recommend-form"); + var submitBtn = document.getElementById("submit-btn"); + var btnLabel = document.getElementById("btn-label"); + var btnLoading = document.getElementById("btn-loading"); + var resultsSection = document.getElementById("results-section"); + var resultsGrid = document.getElementById("results-grid"); + var resultsLoadingEl = document.getElementById("results-loading"); + var resultsEmptyEl = document.getElementById("results-empty"); + var emptyMessageEl = document.getElementById("empty-message"); + var skillsHidden = document.getElementById("skills"); + var skillsTextInput = document.getElementById("skills-input"); + var chipsSelectedEl = document.getElementById("skill-chips-selected"); + var quickPickChips = document.querySelectorAll(".skill-chip"); - // 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 = []; - // 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) { - // 1. Reset standard form dropdowns and fields - recommendForm.reset(); - - // 2. Clear out the internal JavaScript array tracker completely - selectedSkills = []; - - // 3. Clear the hidden inputs and visual chips using the file's own variables - if (skillsHidden) skillsHidden.value = ""; - if (chipsSelectedEl) chipsSelectedEl.innerHTML = ""; - if (skillsTextInput) { - skillsTextInput.value = ""; - skillsTextInput.focus(); // Place cursor back on input - } - - // 4. Hide autocomplete suggestions if any are open - var suggestionsBox = document.getElementById("skills-suggestions"); - if (suggestionsBox) suggestionsBox.innerHTML = ""; - - // 5. Reset quick-pick chip visual active states if they have any - if (quickPickChips) { - quickPickChips.forEach(function(chip) { - chip.classList.remove("active", "selected"); - }); - } - } - }); -} + var clearFiltersBtn = document.getElementById("clear-filters-btn"); + if (clearFiltersBtn) { + clearFiltersBtn.addEventListener("click", function () { + form.reset(); + selectedSkills = []; - // ---------------------------------------------------------- - // Skill chip manager - // ---------------------------------------------------------- + if (skillsHidden) skillsHidden.value = ""; + if (chipsSelectedEl) chipsSelectedEl.innerHTML = ""; + if (skillsTextInput) skillsTextInput.value = ""; + + var suggestionsBox = document.getElementById("skills-suggestions"); + if (suggestionsBox) suggestionsBox.innerHTML = ""; + + quickPickChips.forEach(function (chip) { + chip.classList.remove("active", "selected"); + chip.setAttribute("aria-pressed", "false"); + }); + }); + } - // Skills list for autocomplete (from skills.js) var availableSkills = []; + if (typeof skills !== "undefined" && Array.isArray(skills) && skills.length > 0) { - availableSkills = skills.map(function (s) { return s.label; }); + availableSkills = skills.map(function (s) { + return s.label; + }); } else { - // Fallback if skills.js doesn't load availableSkills = [ "Python", "JavaScript", "Java", "C++", "HTML", "CSS", "React", "Node.js", "Django", "Flask", "SQL", "MongoDB", "AWS", "Docker", "Kubernetes", "Git", "C#", "Ruby", "PHP", "Go", "Swift", "TypeScript", "Angular", "Vue.js", "Spring", "Flutter", "TensorFlow", "PyTorch", "Data Science", "Machine Learning", "Artificial Intelligence", "DevOps", "Cybersecurity", - "Blockchain", "UI/UX Design", "Game Development", "CI/CD", "REST API", "GraphQL", - "Rust", "Kotlin" + "Blockchain", "UI/UX Design", "Game Development", "CI/CD", "REST API", + "GraphQL", "Rust", "Kotlin" ]; } @@ -135,33 +93,6 @@ if (clearFiltersBtn) { var visibleSuggestions = []; var activeSuggestionIndex = -1; - function initSkillStripMarquee() { - var marquee = document.querySelector(".skill-strip-marquee"); - var track = marquee && marquee.querySelector(".skill-strip-track"); - - if (!marquee || !track || track.querySelector(".skill-strip-items[data-marquee-clone='true']")) { - return; - } - - var clone = track.querySelector(".skill-strip-items").cloneNode(true); - clone.setAttribute("aria-hidden", "true"); - clone.setAttribute("data-marquee-clone", "true"); - track.appendChild(clone); - } - - availableSkills = availableSkills.filter(function (skill, index, list) { - return typeof skill === "string" && skill.trim() && - list.findIndex(function (item) { - return item.toLowerCase() === skill.toLowerCase(); - }) === index; - }); - - if (suggestionsDiv) { - suggestionsDiv.setAttribute("role", "listbox"); - } - - initSkillStripMarquee(); - function normalizeSkill(skill) { return skill.trim().toLowerCase(); } @@ -188,12 +119,23 @@ if (clearFiltersBtn) { }).slice(0, 8); } - function syncSuggestionsA11yState() { - skillsTextInput.setAttribute("aria-expanded", visibleSuggestions.length > 0 ? "true" : "false"); + function hideSuggestions() { + visibleSuggestions = []; + activeSuggestionIndex = -1; + + if (suggestionsDiv) { + suggestionsDiv.style.display = "none"; + suggestionsDiv.innerHTML = ""; + } + + if (skillsTextInput) { + skillsTextInput.setAttribute("aria-expanded", "false"); + } } function renderActiveSuggestion() { if (!suggestionsDiv) return; + suggestionsDiv.querySelectorAll(".suggestion-item").forEach(function (item, index) { var isActive = index === activeSuggestionIndex; item.classList.toggle("suggestion-item--active", isActive); @@ -201,16 +143,6 @@ if (clearFiltersBtn) { }); } - function hideSuggestions() { - visibleSuggestions = []; - activeSuggestionIndex = -1; - if (suggestionsDiv) { - suggestionsDiv.style.display = "none"; - suggestionsDiv.innerHTML = ""; - } - syncSuggestionsA11yState(); - } - function selectSuggestion(skill) { addSkill(skill); skillsTextInput.value = ""; @@ -220,22 +152,24 @@ if (clearFiltersBtn) { 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(); }); @@ -251,219 +185,206 @@ if (clearFiltersBtn) { suggestionsDiv.appendChild(item); }); + suggestionsDiv.style.display = "block"; - syncSuggestionsA11yState(); + skillsTextInput.setAttribute("aria-expanded", "true"); } function updateQuickPickState() { quickPickChips.forEach(function (chip) { - var isActive = isSkillSelected(chip.getAttribute("data-skill") || ""); + var skill = chip.getAttribute("data-skill") || ""; + var isActive = isSkillSelected(skill); chip.classList.toggle("active", isActive); chip.setAttribute("aria-pressed", isActive ? "true" : "false"); }); } - // Add skill on Enter key in the text input - // when the user types a skill and hits Enter, add it 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"); - var isAlreadySelected = selectedSkills.some(function (s) { - return s.toLowerCase() === skill.toLowerCase(); - }); - - if (isAlreadySelected) { - removeSkill(skill); - } else { - addSkill(skill); - } - hideSuggestions(); - skillsTextInput.value = ""; - }); - }); - - // 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(); - } - }); - - //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"); } - // 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(); } - // 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.innerHTML = "×"; + 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 + chipEl.appendChild(removeBtn); + chipsSelectedEl.appendChild(chipEl); }); } function syncSkillsHiddenInput() { - if (!skillsHidden){ - var skillsHidden = document.getElementById("skills"); + if (skillsHidden) { + skillsHidden.value = selectedSkills.join(", "); } - // 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(", "); } - updateQuickPickState(); + if (skillsTextInput) { + 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(); + } + }); + + 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)); + } + }); + + skillsTextInput.addEventListener("blur", function () { + setTimeout(function () { + hideSuggestions(); + }, 150); + }); + } + + quickPickChips.forEach(function (chip) { + chip.addEventListener("click", function () { + var skill = chip.getAttribute("data-skill"); + if (isSkillSelected(skill)) { + removeSkill(skill); + } else { + addSkill(skill); + } - // ---------------------------------------------------------- - // Form validation - // ---------------------------------------------------------- + hideSuggestions(); + if (skillsTextInput) skillsTextInput.value = ""; + }); + }); + + if (skillWrap) { + skillWrap.addEventListener("click", function () { + skillsTextInput.focus(); + }); + } + + document.addEventListener("click", function (evt) { + if (skillWrap && !skillWrap.contains(evt.target)) { + hideSuggestions(); + } + }); + + updateQuickPickState(); - //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 + if (el) el.textContent = ""; } - //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; @@ -472,30 +393,24 @@ if (clearFiltersBtn) { return valid; } + form.addEventListener("submit", function (evt) { + evt.preventDefault(); - // ---------------------------------------------------------- - // Form submission and API call - // ---------------------------------------------------------- + clearAllErrors(); - 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 + if (!validateForm()) return; setLoadingState(true); - // Allow browser to paint spinner before request starts requestAnimationFrame(function () { - var payload = { - skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), + skills: skillsHidden.value.trim(), level: document.getElementById("level").value, interest: document.getElementById("interest").value, time: document.getElementById("time").value @@ -510,24 +425,29 @@ if (clearFiltersBtn) { return res.json(); }) .then(function (data) { - setLoadingState(false); if (data.error) { var generalErr = document.getElementById("form-error-general"); - - if (generalErr) { - generalErr.textContent = data.error; - } - + if (generalErr) generalErr.textContent = data.error; return; } renderResults(data.projects || [], data.message); }) - .catch(function () { - + .catch(function (err) { setLoadingState(false); + + + var generalErr = document.getElementById("form-error-general"); + if (generalErr) { + generalErr.textContent = "Something went wrong. Please try again."; + } + + console.error("API request failed:", err); + }); + }); + //combine form values into an object to send to server/api var payload = { // Prefer the hidden input value; fall back to raw text box if hidden input is empty @@ -536,48 +456,46 @@ if (clearFiltersBtn) { interest: document.getElementById("interest").value, time: document.getElementById("time").value }; + }); - // 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 ? "true" : "false"); + + btnLabel.style.display = isLoading ? "none" : "inline"; + btnLoading.style.display = isLoading ? "inline" : "none"; + submitBtn.setAttribute("aria-busy", isLoading); btnLabel.style.display = isLoading ? "none" : "inline"; btnLoading.style.display = isLoading ? "inline-flex" : "none"; + if (isLoading) { - // Show the results section with only the loading indicator visible resultsSection.style.display = "block"; resultsLoadingEl.style.display = "block"; resultsGrid.style.display = "none"; resultsEmptyEl.style.display = "none"; - // Scroll down so the user can see the spinner without manually scrolling 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) { - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "block"; resultsGrid.style.display = "none"; resultsEmptyEl.style.display = "block"; + + + if (message && emptyMessageEl) { + emptyMessageEl.textContent = message; + if (message && emptyMessageEl) emptyMessageEl.textContent = message; if (!projects || projects.length === 0) { //if no projects returned from api, show the "no results" message and hide the grid resultsGrid.style.display = "none"; @@ -591,6 +509,7 @@ if (clearFiltersBtn) { emptyMessageEl.textContent = message; } else { emptyMessageEl.textContent = "Try adjusting your skills or choosing a different interest area."; + } resultsSection.scrollIntoView({ behavior: "smooth" }); @@ -600,7 +519,6 @@ if (clearFiltersBtn) { 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)); }); @@ -608,52 +526,44 @@ if (clearFiltersBtn) { resultsSection.scrollIntoView({ behavior: "smooth" }); } - // builds one project card as a DOM element and returns it - // the card has title, short description, tags and link function buildProjectCard(project) { var card = document.createElement("div"); card.className = "project-card"; - // 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"; + + (project.skills || []).slice(0, 2).forEach(function (skill) { + // 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" var levelClass = "level " + (project.level || "").toLowerCase(); tagsRow.appendChild(createTag(project.level, levelClass)); - // Time tag tagsRow.appendChild(createTag("Time: " + project.time, "time")); - // Footer with view-details link var footer = document.createElement("div"); footer.className = "project-card-footer"; 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 + link.href = "/project/" + project.id; footer.appendChild(link); - // Assemble the card in order card.appendChild(title); card.appendChild(desc); card.appendChild(tagsRow); @@ -662,84 +572,138 @@ if (clearFiltersBtn) { return card; } - // helper to create a coloured tag element (used for skills, level, time tags on the cards) function createTag(text, type) { var span = document.createElement("span"); - // The type becomes a BEM modifier so CSS can style each tag differently span.className = "project-tag project-tag--" + type; - span.textContent = text; + span.textContent = text || ""; return span; } 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; } -} // end isIndexPage + // GitHub modal + if ( + openModalBtn && + closeModalBtn && + modal && + githubInput && + fetchBtn && + errorMsg + ) { + openModalBtn.addEventListener("click", function (e) { + e.preventDefault(); + modal.classList.add("active"); + githubInput.focus(); + }); + + function closeGithubModal() { + modal.classList.remove("active"); + githubInput.value = ""; + errorMsg.textContent = ""; + } + + closeModalBtn.addEventListener("click", closeGithubModal); + + modal.addEventListener("click", function (e) { + if (e.target === modal) closeGithubModal(); + }); + + fetchBtn.addEventListener("click", async function () { + var username = githubInput.value.trim(); + if (!username) return; + + fetchBtn.disabled = true; + fetchBtn.textContent = "Syncing..."; + + try { + var response = await fetch("https://api.github.com/users/" + username + "/repos"); + + if (!response.ok) { + throw new Error("Failed to fetch skills"); + } + + var repos = await response.json(); + var langs = [...new Set(repos.map(function (r) { + return r.language; + }).filter(Boolean))]; + if (langs.length > 0) { + langs.forEach(function (lang) { + 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"; + } + }); + } +} -// ============================================================ // DETAIL PAGE -// ============================================================ if (isDetailPage) { + 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 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
-
-  // Cache flag so code is only fetched once per page load
   var codeFetched = false;
 
-  //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
     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 (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 () {
@@ -749,57 +713,68 @@ if (isDetailPage) {
       });
   }
 
-  // Attach open/close handlers
+  function renderCodeWithLineNumbers(code) {
+    return code.split("\n").map(function (line, index) {
+      var row = document.createElement("div");
+      row.className = "code-line";
+
+      var lineNumber = document.createElement("span");
+      lineNumber.className = "line-number";
+      lineNumber.textContent = index + 1;
+
+      var lineContent = document.createElement("span");
+      lineContent.className = "line-content";
+      lineContent.textContent = line;
+
+      row.appendChild(lineNumber);
+      row.appendChild(lineContent);
+
+      return row;
+    });
+  }
+
   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
+    codePanelOverlay.addEventListener("click", closeCodePanel);
   }
 
-  // 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 (evt.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; 
+  var btnCopyCode = document.getElementById("btn-copy-code");
+  var copyToast = document.getElementById("copy-toast");
+  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 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");
-    }
+    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);
   }
@@ -808,125 +783,63 @@ if (isDetailPage) {
     btnCopyCode.addEventListener("click", function () {
       var code = codeContentEl
         ? Array.from(codeContentEl.querySelectorAll(".line-content"))
-          .map(function (el) { return el.textContent; })
-          .join("\n")
+            .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
       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(showCopySuccess)
+          .catch(function () {
+            fallbackCopy(code);
+          });
       } else {
-        fallbackCopy(code); // Clipboard API not supported, use fallback method
+        fallbackCopy(code);
       }
     });
   }
 
-  // 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();
-  });
-
-  // 2. Close Github Input Modal
-  const closeGithubModal = () => {
-      modal.classList.remove('active');
-      githubInput.value = '';
-      errorMsg.textContent = '';
-  };
+    try {
+      document.execCommand("copy");
+      showCopySuccess();
+    } catch (e) {}
 
-  closeModalBtn.addEventListener('click', closeGithubModal);
-
-  // Close on clicking outside the card
-  modal.addEventListener('click', (e) => {
-      if (e.target === modal) closeGithubModal();
-  });
-
-  // 3. Fetch Skills Logic
-  fetchBtn.addEventListener('click', async () => {
-      const username = githubInput.value.trim();
-      if (!username) return;
-
-      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';
-      }
-  });
+    document.body.removeChild(ta);
+  }
 }
 
-/* ---- Scroll-to-top button ---- */
-
-/* Show the button only when the user has scrolled more than 300px */
+// Scroll-to-top button
 var SCROLL_THRESHOLD = 300;
+var scrollTopBtn = document.getElementById("scroll-top-btn");
 
-/* Get the button element; guard against pages that do not have it */
-var scrollTopBtn = document.getElementById('scroll-top-btn');
-
-/* Add or remove the .visible class based on scroll position */
 function handleScroll() {
   if (!scrollTopBtn) return;
+
   if (window.pageYOffset > SCROLL_THRESHOLD) {
-    scrollTopBtn.classList.add('visible');
+    scrollTopBtn.classList.add("visible");
   } else {
-    scrollTopBtn.classList.remove('visible');
+    scrollTopBtn.classList.remove("visible");
   }
 }
 
-/* Smooth-scroll to the very top of the page */
 function scrollToTop() {
-  window.scrollTo({ top: 0, behavior: 'smooth' });
+  window.scrollTo({ top: 0, behavior: "smooth" });
 }
 
-/* Only wire up listeners if the button exists on this page */
 if (scrollTopBtn) {
-    window.addEventListener('scroll', handleScroll);
-    scrollTopBtn.addEventListener('click', scrollToTop);
-}
+  window.addEventListener("scroll", handleScroll);
+  scrollTopBtn.addEventListener("click", scrollToTop);
+}
\ No newline at end of file
diff --git a/utils/file_server.py b/utils/file_server.py
index 5539060e..1dc98bf5 100644
--- a/utils/file_server.py
+++ b/utils/file_server.py
@@ -1,49 +1,44 @@
-# utils/file_server.py
-# Handles safe resolution and serving of starter code files.
-
 import os
 
-# Absolute path to the starter_code directory
-STARTER_CODE_DIR = os.path.abspath(
-    os.path.join(os.path.dirname(__file__), "..", "starter_code")
-)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+STARTER_CODE_DIR = os.path.join(BASE_DIR, "starter_codes")
+
+
+def get_starter_code_dir():
+    return STARTER_CODE_DIR
 
 
 def resolve_starter_file(project):
-    """
-    Given a project dict, return the absolute path to its starter code file.
-    Returns None if the project has no starter_code field or the file does not exist.
-    """
-    raw_path = project.get("starter_code", "")
-    if not raw_path:
+  filename = project.get("starter_code_file") or project.get("starter_code")
+  if filename:
+    filename = filename.replace("starter_code/", "").replace("starter_codes/", "")
+
+    if not filename:
         return None
 
-    # Only use the filename portion — never trust a full path from the data file
-    filename = os.path.basename(raw_path)
     full_path = os.path.join(STARTER_CODE_DIR, filename)
 
-    if not os.path.exists(full_path):
+    if not os.path.isfile(full_path):
         return None
 
     return full_path
 
 
 def read_starter_code(project):
-    """
-    Return a dict containing the filename and text content of the starter file.
-    Returns None if the file cannot be found.
-    """
     full_path = resolve_starter_file(project)
+
     if not full_path:
         return None
 
-    filename = os.path.basename(full_path)
-    with open(full_path, "r", encoding="utf-8") as f:
-        code = f.read()
-
-    return {"filename": filename, "code": code}
+    try:
+        with open(full_path, "r", encoding="utf-8") as f:
+            code = f.read()
 
+        return {
+            "filename": os.path.basename(full_path),
+            "code": code
+        }
 
-def get_starter_code_dir():
-    """Return the absolute path to the starter_code directory for use with send_from_directory."""
-    return STARTER_CODE_DIR
+    except Exception:
+        return None
diff --git a/utils/recommender.py b/utils/recommender.py
index 8a53c64b..27aaf302 100644
--- a/utils/recommender.py
+++ b/utils/recommender.py
@@ -5,7 +5,7 @@
 from utils.data_loader import load_all_projects
 
 # Maximum number of recommendations returned to the user
-MAX_RESULTS = 3
+MAX_RESULTS = 10
 
 # Scoring weights used by the recommendation engine.
 # Higher weights mean that criterion has more influence