diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0728ec..93b83eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/script.js b/static/script.js index 28dbbeb8..5ec07ad1 100644 --- a/static/script.js +++ b/static/script.js @@ -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"); @@ -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) { @@ -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)); diff --git a/static/style.css b/static/style.css index ae3e9a03..dbe8e243 100644 --- a/static/style.css +++ b/static/style.css @@ -3753,36 +3753,117 @@ html[data-theme="dark"] .btn-view-code-sm { color: #e6edf3; } -@media (prefers-reduced-motion: reduce) { - html:focus-within { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } +/* ---- Share Result button ---- */ +.share-result-wrap { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; } -.theme-toggle { - width: 42px; - height: 42px; - border-radius: 12px; - border: 1px solid rgba(255,255,255,0.15); - background: rgba(255,255,255,0.08); - color: white; +.btn-share { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--white); + color: var(--text-body); + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 500; cursor: pointer; - font-size: 1rem; - transition: all 0.25s ease; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; } -.theme-toggle:hover { - background: rgba(255,255,255,0.18); - transform: translateY(-2px); +.btn-share:hover { + background: var(--indigo-600); + color: #fff; + border-color: var(--indigo-600); + transform: translateY(-1px); +} + +.btn-share.copied { + background: var(--green-500); + color: #fff; + border-color: var(--green-500); } -/* ============================================================ - WORKING DARK MODE +/* ---- Share Toast notification ---- */ +.share-toast { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--gray-900); + color: #fff; + padding: 12px 24px; + border-radius: var(--r-md); + font-family: var(--font-body); + font-size: 0.85rem; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; + z-index: 1000; + box-shadow: var(--shadow-lg); +} + +.share-toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + pointer-events: auto; +} + +/* ---- Share Prefill Banner ---- */ +.share-prefill-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + margin-bottom: 24px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(139, 92, 246, 0.08)); + border: 1px solid rgba(99, 102, 241, 0.22); + border-radius: var(--r-sm); + font-size: 0.88rem; + line-height: 1.6; + color: var(--text-body); + animation: bannerSlideIn 0.35s ease-out; +} + +@keyframes bannerSlideIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.share-prefill-banner-content { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.share-prefill-banner-content svg { + flex-shrink: 0; + margin-top: 3px; + color: var(--indigo-600); +} + +.share-prefill-banner-close { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + color: var(--gray-400); + padding: 0 4px; + line-height: 1; + flex-shrink: 0; + transition: color var(--t); +} + +.share-prefill-banner-close:hover { + color: var(--text-heading); +} diff --git a/templates/index.html b/templates/index.html index 5c149c1f..ad1a4f81 100644 --- a/templates/index.html +++ b/templates/index.html @@ -518,7 +518,24 @@