Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- "Share My Result" button on results page that copies a pre-filled URL to clipboard (#411)
- Auto-fill form and trigger recommendations when opening a shared URL (#411)
- Initial CHANGELOG.md setup for tracking project history
- Documentation structure for future contributor updates
- Added .flake8 config file to enforce consistent 88-character line limit for all contributors
Expand Down
298 changes: 286 additions & 12 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,74 @@ updateProfileWidgets();
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 () {

//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(),
level: document.getElementById("level").value,
interest: document.getElementById("interest").value,
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)
})
.then(function (res) {
return res.json();
})
.then(function (data) {

clearAllErrors();

if (skillsTextInput.value.trim()) {
addSkill(skillsTextInput.value);
skillsTextInput.value = "";
hideSuggestions();
}

if (!validateForm()) return;

setLoadingState(true);

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) {
submitBtn.disabled = isLoading;
submitBtn.setAttribute("aria-busy", isLoading ? "true" : "false");
Expand All @@ -469,16 +537,39 @@ updateProfileWidgets();
}
}

function truncate(text, maxLength) {
text = text || "";
return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
}

function createTag(text, type) {
var span = document.createElement("span");
span.className = "project-tag project-tag--" + normalize(type).replace(/[^a-z0-9_-]/g, "-");
span.textContent = text;
return span;
// ----------------------------------------------------------
// Render result cards
// ----------------------------------------------------------

// Renders project result cards or shows the empty-state message.
// Uses a single consolidated check to toggle between states.
function renderResults(projects, message) {
resultsSection.style.display = "block";
resultsLoadingEl.style.display = "none";
resultsGrid.innerHTML = "";

var shareWrap = document.getElementById("share-result-wrap");
var hasResults = projects && projects.length > 0;

// Single consolidated toggle for empty vs. populated state
resultsGrid.style.display = hasResults ? "grid" : "none";
resultsEmptyEl.style.display = hasResults ? "none" : "block";
if (shareWrap) shareWrap.style.display = hasResults ? "flex" : "none";

if (!hasResults) {
if (message && emptyMessageEl) emptyMessageEl.textContent = message;
resultsSection.scrollIntoView({ behavior: "smooth" });
return;
}

// Build a card for each project and add it to the grid
projects.forEach(function (project) {
resultsGrid.appendChild(buildProjectCard(project));
});

resultsSection.scrollIntoView({ behavior: "smooth" });
main
}

function buildProjectCard(project) {
Expand Down Expand Up @@ -550,9 +641,192 @@ updateProfileWidgets();
resultsSection.scrollIntoView({ behavior: "smooth" });
}

skillsInput.setAttribute("role", "combobox");
skillsInput.setAttribute("aria-expanded", "false");
suggestions.setAttribute("role", "listbox");

// ----------------------------------------------------------
// Share My Result — build URL and copy to clipboard
// ----------------------------------------------------------

var MAX_SHARE_SKILLS = 10;
var MAX_URL_LENGTH = 2000;

// Build a shareable URL from the current form selections.
// Caps skill count and enforces a max URL length to avoid oversized links.
function buildShareUrl() {
var baseUrl = window.location.origin + window.location.pathname;
var params = new URLSearchParams();
var allSkills = skillsHidden.value.trim();
var skillsArr = [];
var truncated = false;

if (allSkills) {
skillsArr = allSkills.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
if (skillsArr.length > MAX_SHARE_SKILLS) {
skillsArr = skillsArr.slice(0, MAX_SHARE_SKILLS);
truncated = true;
}
params.set("skills", skillsArr.join(", "));
}

params.set("level", document.getElementById("level").value);
params.set("interest", document.getElementById("interest").value);
params.set("time", document.getElementById("time").value);

var url = baseUrl + "?" + params.toString();

// Progressively trim skills if URL still exceeds safe browser limit
while (url.length > MAX_URL_LENGTH && skillsArr.length > 1) {
skillsArr.pop();
truncated = true;
params.set("skills", skillsArr.join(", "));
url = baseUrl + "?" + params.toString();
}

return { url: url, truncated: truncated };
}

var shareBtn = document.getElementById("share-result-btn");
var shareToast = document.getElementById("share-toast");
var shareToastTimeout = null;
var _shareWasTruncated = false;

// Show the "Copied!" state on the share button and display the toast.
// If skills were truncated, the label indicates the truncation.
function showShareSuccess() {
if (!shareBtn) return;
var originalLabel = shareBtn.querySelector(".share-btn-label");
var labelText = _shareWasTruncated ? "Copied! (some skills trimmed)" : "Copied!";
if (originalLabel) originalLabel.textContent = labelText;
shareBtn.classList.add("copied");

if (shareToast) shareToast.classList.add("show");

// Auto-reset after 2.5 seconds
clearTimeout(shareToastTimeout);
shareToastTimeout = setTimeout(function () {
if (originalLabel) originalLabel.textContent = "Share My Result";
shareBtn.classList.remove("copied");
if (shareToast) shareToast.classList.remove("show");
}, 2500);
}

// Fallback clipboard copy using a hidden textarea (for older browsers)
function fallbackShareCopy(text) {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand("copy"); showShareSuccess(); } catch (e) { /* silent fail */ }
document.body.removeChild(ta);
}

if (shareBtn) {
shareBtn.addEventListener("click", function () {
var result = buildShareUrl();
var url = result.url;
_shareWasTruncated = result.truncated;

// Use Clipboard API with textarea fallback
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function () {
showShareSuccess();
}).catch(function () {
fallbackShareCopy(url);
});
} else {
fallbackShareCopy(url);
}
});
}


// ----------------------------------------------------------
// Query param validation for shared URLs
// ----------------------------------------------------------

var VALID_LEVELS = ["Beginner", "Intermediate", "Advanced"];
var VALID_INTERESTS = ["Web", "Data", "Education", "Automation", "Games"];
var VALID_TIMES = ["Low", "Medium", "High"];

// Strip HTML tags and restrict to safe characters for skill values
function sanitizeSkillValue(raw) {
if (!raw || typeof raw !== "string") return "";
// Remove any HTML/script tags
var cleaned = raw.replace(/<[^>]*>/g, "");
// Allow only safe characters: letters, digits, spaces, dots, #, +, _, -, /
cleaned = cleaned.replace(/[^A-Za-z0-9 .#+_\-\/]/g, "");
return cleaned.trim();
}

// Return the value only if it appears in the allowlist, otherwise ""
function validateDropdownValue(value, allowlist) {
if (!value || typeof value !== "string") return "";
var trimmed = value.trim();
for (var i = 0; i < allowlist.length; i++) {
if (allowlist[i] === trimmed) return trimmed;
}
return "";
}


// ----------------------------------------------------------
// Auto-fill from shared URL query params (no auto-submit)
// ----------------------------------------------------------

// Pre-fill form from URL params but require user to click Generate
(function initFromQueryParams() {
var params = new URLSearchParams(window.location.search);
var qSkills = params.get("skills");
var qLevel = params.get("level");
var qInterest = params.get("interest");
var qTime = params.get("time");

// Only auto-fill if all four params are present
if (!qSkills || !qLevel || !qInterest || !qTime) return;

// Validate dropdown values against their allowlists
var safeLevel = validateDropdownValue(qLevel, VALID_LEVELS);
var safeInterest = validateDropdownValue(qInterest, VALID_INTERESTS);
var safeTime = validateDropdownValue(qTime, VALID_TIMES);

// Abort if any dropdown value is invalid
if (!safeLevel || !safeInterest || !safeTime) return;

// Sanitize and add each skill from the comma-separated query param
qSkills.split(",").forEach(function (s) {
var safe = sanitizeSkillValue(s);
if (safe) addSkill(safe);
});

// Set dropdown values to the validated selections
document.getElementById("level").value = safeLevel;
document.getElementById("interest").value = safeInterest;
document.getElementById("time").value = safeTime;

// Show the prefill banner instead of auto-submitting
var banner = document.getElementById("share-prefill-banner");
var bannerClose = document.getElementById("share-prefill-banner-close");
if (banner) {
banner.style.display = "flex";
if (bannerClose) {
bannerClose.addEventListener("click", function () {
banner.style.display = "none";
});
}
// Scroll form into view so user sees the pre-filled state
var formSection = document.getElementById("find-project");
if (formSection) formSection.scrollIntoView({ behavior: "smooth" });
}
})();

} // end isIndexPage


// ============================================================
// DETAIL PAGE
// ============================================================
if (isDetailPage) {

skillsInput.addEventListener("input", function () {
showSuggestions(filteredSkills(skillsInput.value));
Expand Down
Loading
Loading