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
65 changes: 40 additions & 25 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
// ============================================================
// Detect which page we are on
// ============================================================
var isIndexPage = !!document.getElementById("recommend-form");
// !! 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";


Expand All @@ -22,11 +24,14 @@ var isDetailPage = typeof PROJECT_ID !== "undefined";
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);
});

Expand Down Expand Up @@ -116,13 +121,11 @@ if (isIndexPage) {
var matchedSkill = availableSkills.find(function (skill) {
return normalizeSkill(skill) === normalizedSkill;
});

return matchedSkill || rawSkill.trim();
}

function getFilteredSkills(query) {
var normalizedQuery = normalizeSkill(query);

return availableSkills.filter(function (skill) {
return normalizeSkill(skill).includes(normalizedQuery) && !isSkillSelected(skill);
}).slice(0, 8);
Expand All @@ -134,7 +137,6 @@ if (isIndexPage) {

function renderActiveSuggestion() {
if (!suggestionsDiv) return;

suggestionsDiv.querySelectorAll(".suggestion-item").forEach(function (item, index) {
var isActive = index === activeSuggestionIndex;
item.classList.toggle("suggestion-item--active", isActive);
Expand All @@ -145,12 +147,10 @@ if (isIndexPage) {
function hideSuggestions() {
visibleSuggestions = [];
activeSuggestionIndex = -1;

if (suggestionsDiv) {
suggestionsDiv.style.display = "none";
suggestionsDiv.innerHTML = "";
}

syncSuggestionsA11yState();
}

Expand All @@ -163,15 +163,12 @@ if (isIndexPage) {

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");
Expand All @@ -197,7 +194,6 @@ if (isIndexPage) {

suggestionsDiv.appendChild(item);
});

suggestionsDiv.style.display = "block";
syncSuggestionsA11yState();
}
Expand All @@ -216,9 +212,7 @@ if (isIndexPage) {
if (visibleSuggestions.length === 0) {
displaySuggestions(getFilteredSkills(skillsTextInput.value));
}

if (visibleSuggestions.length === 0) return;

evt.preventDefault();
if (evt.key === "ArrowDown") {
activeSuggestionIndex = (activeSuggestionIndex + 1) % visibleSuggestions.length;
Expand All @@ -227,7 +221,6 @@ if (isIndexPage) {
? visibleSuggestions.length - 1
: activeSuggestionIndex - 1;
}

renderActiveSuggestion();
return;
}
Expand All @@ -239,30 +232,25 @@ if (isIndexPage) {

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();
}
});

// Show suggestions on input
skillsTextInput.addEventListener("input", function (evt) {
var typedValue = evt.target.value.trim();

if (typedValue.length === 0) {
hideSuggestions();
return;
}

displaySuggestions(getFilteredSkills(typedValue));
});

Expand Down Expand Up @@ -299,7 +287,9 @@ if (isIndexPage) {
});

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)
Expand All @@ -309,20 +299,22 @@ if (isIndexPage) {
renderSelectedChips();
syncSkillsHiddenInput();
updateQuickPickState();
// Once a skill is added, remove the "please add a skill" error if it was showing
clearFieldError("skills-error");
}

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();
}

function renderSelectedChips() {
// Wipe out old chips first so we don't end up with duplicates in the UI
chipsSelectedEl.innerHTML = "";
selectedSkills.forEach(function (skill) {
var chipEl = document.createElement("span");
Expand All @@ -336,6 +328,7 @@ if (isIndexPage) {
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);
});
Expand All @@ -347,6 +340,7 @@ if (isIndexPage) {

function syncSkillsHiddenInput() {
// Keep the hidden <input> in sync for form serialisation
// The API expects a comma-separated string, so join the array that way
skillsHidden.value = selectedSkills.join(", ");
}

Expand Down Expand Up @@ -376,6 +370,7 @@ if (isIndexPage) {
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;
Expand Down Expand Up @@ -416,10 +411,11 @@ if (isIndexPage) {
setLoadingState(true);

var payload = {
skills: skillsHidden.value.trim() || skillsTextInput.value.trim(),
level: document.getElementById("level").value,
// Prefer the hidden input value; fall back to raw text box if hidden input is empty
skills: skillsHidden.value.trim() || skillsTextInput.value.trim(),
level: document.getElementById("level").value,
interest: document.getElementById("interest").value,
time: document.getElementById("time").value
time: document.getElementById("time").value
};

fetch("/api/recommend", {
Expand All @@ -430,13 +426,11 @@ if (isIndexPage) {
.then(function (res) { return res.json(); })
.then(function (data) {
setLoadingState(false);

if (data.error) {
var generalErr = document.getElementById("form-error-general");
if (generalErr) generalErr.textContent = data.error;
return;
}

renderResults(data.projects || [], data.message);
})
.catch(function (err) {
Expand All @@ -448,6 +442,7 @@ if (isIndexPage) {
});

function setLoadingState(isLoading) {
// Disable the button so the user can't accidentally submit twice
submitBtn.disabled = isLoading;
btnLabel.style.display = isLoading ? "none" : "inline";
btnLoading.style.display = isLoading ? "inline" : "none";
Expand All @@ -458,6 +453,7 @@ if (isIndexPage) {
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";
Expand All @@ -473,6 +469,7 @@ if (isIndexPage) {
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) {
Expand Down Expand Up @@ -505,6 +502,7 @@ if (isIndexPage) {
// 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
Expand All @@ -517,6 +515,7 @@ if (isIndexPage) {
});

// 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));

Expand Down Expand Up @@ -544,13 +543,16 @@ if (isIndexPage) {

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;
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;
}

Expand All @@ -574,22 +576,27 @@ if (isDetailPage) {
var codeFetched = false;

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();
}

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 = "";
}

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")
Expand All @@ -601,6 +608,7 @@ if (isDetailPage) {
}
if (codePanelFilename) codePanelFilename.textContent = data.filename;
if (codeContentEl) codeContentEl.textContent = data.code;
// Mark as fetched so we don't hit the API again on the next open
codeFetched = true;
})
.catch(function () {
Expand All @@ -619,6 +627,7 @@ if (isDetailPage) {
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();
});
Expand All @@ -642,6 +651,7 @@ if (isDetailPage) {
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
Expand All @@ -650,6 +660,7 @@ if (isDetailPage) {
}

// 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";
Expand All @@ -664,6 +675,7 @@ if (isDetailPage) {
if (btnCopyCode) {
btnCopyCode.addEventListener("click", function () {
var code = codeContentEl ? codeContentEl.textContent : "";
// 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
Expand All @@ -678,14 +690,17 @@ if (isDetailPage) {
}

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
} // end isDetailPage
Loading