Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 80 additions & 51 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ var isDetailPage = typeof PROJECT_ID !== "undefined";
// Mobile navigation toggle (runs on all pages)
// ============================================================
(function initMobileNav() {
var toggle = document.getElementById("nav-mobile-toggle");
var menu = document.getElementById("nav-mobile-menu");
var toggle = document.getElementById("nav-mobile-toggle"); //hamburger button
var menu = document.getElementById("nav-mobile-menu"); //dropdown menu

// Nothing to do if the nav isn't on this page, just bail out
if (!toggle || !menu) return;
Expand All @@ -36,9 +36,9 @@ var isDetailPage = typeof PROJECT_ID !== "undefined";
});

// 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");
});
});
Expand All @@ -51,19 +51,20 @@ var isDetailPage = typeof PROJECT_ID !== "undefined";
if (isIndexPage) {

// DOM references
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");
// 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 = [];
Expand Down Expand Up @@ -208,6 +209,7 @@ if (isIndexPage) {
}

// 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) {
Expand Down Expand Up @@ -297,6 +299,7 @@ if (isIndexPage) {
}
});

//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);
Expand All @@ -314,6 +317,7 @@ if (isIndexPage) {
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) {
Expand All @@ -324,28 +328,31 @@ if (isIndexPage) {
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
// Remove button for each chip (create lil "x" button)
var removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "skill-chip-remove";
removeBtn.innerHTML = "×";
removeBtn.setAttribute("aria-label", "Remove " + skill);
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);
chipsSelectedEl.appendChild(chipEl);
chipEl.appendChild(removeBtn); // put x button inside the chip
chipsSelectedEl.appendChild(chipEl); //add chip to page
});
}

Expand All @@ -362,22 +369,27 @@ if (isIndexPage) {
// 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 = "";
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;

Expand Down Expand Up @@ -408,19 +420,20 @@ if (isIndexPage) {
// ----------------------------------------------------------

form.addEventListener("submit", function (evt) {
evt.preventDefault();
clearAllErrors();

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;
if (!validateForm()) return; //stop - anything missing/invalid

setLoadingState(true);

//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
skills: skillsHidden.value.trim() || skillsTextInput.value.trim(),
Expand All @@ -429,12 +442,13 @@ if (isIndexPage) {
time: document.getElementById("time").value
};

//post the data to backend api as JSON, then handle the response
fetch("/api/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
body: JSON.stringify(payload) //convert object to json string
})
.then(function (res) { return res.json(); })
.then(function (res) { return res.json(); }) //parse the response as JSON
.then(function (data) {
setLoadingState(false);
if (data.error) {
Expand All @@ -445,13 +459,15 @@ if (isIndexPage) {
renderResults(data.projects || [], data.message);
})
.catch(function (err) {
// this runs if the network request itself fails
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);
});
});

// 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;
Expand All @@ -467,8 +483,8 @@ if (isIndexPage) {
// 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";
resultsLoadingEl.style.display = "none";
resultsGrid.style.display = "grid"; //switch back to gird layout
}
}

Expand All @@ -477,30 +493,35 @@ if (isIndexPage) {
// 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";
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";
resultsEmptyEl.style.display = "block";
if (message && emptyMessageEl) emptyMessageEl.textContent = message; //if api sent back a message (e.g. "no projects found matching your criteria"), show that
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" });
}

// 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";
Expand Down Expand Up @@ -540,10 +561,11 @@ if (isIndexPage) {
var link = document.createElement("a");
link.className = "btn-details";
link.textContent = "View Full Project";
link.href = "/project/" + project.id;
link.href = "/project/" + project.id; //each project has a unique id

footer.appendChild(link);

// Assemble the card in order
card.appendChild(title);
card.appendChild(desc);
card.appendChild(tagsRow);
Expand All @@ -552,6 +574,7 @@ if (isIndexPage) {
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
Expand All @@ -575,17 +598,18 @@ if (isIndexPage) {
// ============================================================
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"); // <pre> 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;
Expand All @@ -598,6 +622,7 @@ if (isDetailPage) {
if (!codeFetched) fetchStarterCode();
}

//closes the code panel and hides the overlay
function closeCodePanel() {
if (!codePanel) return;
codePanel.classList.remove("active");
Expand All @@ -606,6 +631,8 @@ if (isDetailPage) {
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...";
Expand Down Expand Up @@ -635,25 +662,26 @@ if (isDetailPage) {
if (btnClosePanel) btnClosePanel.addEventListener("click", closeCodePanel);

if (codePanelOverlay) {
codePanelOverlay.addEventListener("click", closeCodePanel);
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();
if (evt.key === "Escape") closeCodePanel(); //esc key to close
});

// ----------------------------------------------------------
// Copy Code button
// ----------------------------------------------------------
var btnCopyCode = document.getElementById("btn-copy-code");
var copyToast = document.getElementById("copy-toast");
var toastTimeout = null;
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
// 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");
Expand Down Expand Up @@ -692,14 +720,15 @@ if (isDetailPage) {
// Use Clipboard API with textarea fallback
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(showCopySuccess).catch(function () {
fallbackCopy(code);
fallbackCopy(code); // clipboard api failed, try the old way
});
} else {
fallbackCopy(code);
fallbackCopy(code); // Clipboard API not supported, use fallback method
}
});
}

// 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");
Expand Down
Loading