diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb0728e..93b83eb 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 8da1561..4a7c55b 100644
--- a/static/script.js
+++ b/static/script.js
@@ -1,134 +1,169 @@
-// 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)
+// DevPath client-side behavior.
-// ============================================================
-// 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 POINTS_PER_SEARCH = 5;
+var POINTS_PER_VIEW = 10;
+var POINTS_PER_CODE_OPEN = 15;
+var POINTS_PER_COMPLETION = 30;
+var PROGRESS_MAX_POINTS = 450;
+(function () {
+ var html = document.documentElement;
-// ============================================================
-// Mobile navigation toggle (runs on all pages)
-// ============================================================
-(function initMobileNav() {
- var toggle = document.getElementById("nav-mobile-toggle"); //hamburger button
- var menu = document.getElementById("nav-mobile-menu"); //dropdown menu
+ function applyTheme(theme) {
+ var isDark = theme === "dark";
+ html.setAttribute("data-theme", theme);
+ try {
+ localStorage.setItem("theme", theme);
+ } catch (err) {
+ // Storage can be unavailable in private browsing.
+ }
+
+ document.querySelectorAll(".theme-toggle").forEach(function (button) {
+ button.setAttribute("aria-pressed", isDark ? "true" : "false");
+ button.setAttribute("aria-label", isDark ? "Switch to light mode" : "Switch to dark mode");
+ });
+ }
+
+ function initTheme() {
+ var theme = html.getAttribute("data-theme") || "light";
+ applyTheme(theme);
+ requestAnimationFrame(function () {
+ html.classList.add("theme-ready");
+ });
+ }
+
+ document.addEventListener("click", function (event) {
+ var toggle = event.target.closest(".theme-toggle");
+ if (!toggle) return;
+ event.preventDefault();
+ var current = html.getAttribute("data-theme") || "light";
+ applyTheme(current === "dark" ? "light" : "dark");
+ });
- // Nothing to do if the nav isn't on this page, just bail out
+ initTheme();
+})();
+
+(function initMobileNav() {
+ var toggle = document.getElementById("nav-mobile-toggle");
+ var menu = document.getElementById("nav-mobile-menu");
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");
+ function setOpen(isOpen) {
+ menu.classList.toggle("open", isOpen);
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);
+ toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
+ }
+
+ toggle.addEventListener("click", function () {
+ setOpen(!menu.classList.contains("open"));
});
- // Close menu when any mobile link is clicked
- menu.querySelectorAll(".nav-mobile-link").forEach(function (link) {
- link.addEventListener("click", function () {
- menu.classList.remove("open");
- toggle.classList.remove("open");
+ menu.querySelectorAll(".nav-mobile-link").forEach(function (link) {
+ link.addEventListener("click", function () {
+ setOpen(false);
});
});
-})();
+ window.addEventListener("resize", function () {
+ if (window.innerWidth >= 640) setOpen(false);
+ });
+})();
-// ============================================================
-// INDEX PAGE
-// ============================================================
-if (isIndexPage) {
-
- // 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 = [];
-
-// Points awarded per action
-var POINTS_PER_SEARCH = 5;
-var POINTS_PER_VIEW = 10;
-var POINTS_PER_CODE_OPEN = 15;
-var POINTS_PER_COMPLETION = 30;
-
-var PROGRESS_TARGET_SEARCHES = 10;
-var PROGRESS_TARGET_VIEWS = 10;
-var PROGRESS_TARGET_CODE_OPENS = 10;
-var PROGRESS_TARGET_COMPLETIONS = 5;
+var STORAGE_KEY = "devpathUserProgress";
+var progress = {
+ searches: 0,
+ projectViews: 0,
+ codeOpens: 0,
+ completions: 0,
+ points: 0,
+ viewedProjects: [],
+ completedProjects: [],
+ achievements: [],
+ badges: {
+ first_search: false,
+ project_explorer: false,
+ code_starter: false,
+ completionist: false,
+ roadmap_runner: false
+ },
+ bestScore: 0
+};
+
+function loadProgressState() {
+ try {
+ var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
+ if (!saved || typeof saved !== "object") return;
+ progress = Object.assign(progress, saved);
+ progress.viewedProjects = Array.isArray(saved.viewedProjects) ? saved.viewedProjects : [];
+ progress.completedProjects = Array.isArray(saved.completedProjects) ? saved.completedProjects : [];
+ progress.achievements = Array.isArray(saved.achievements) ? saved.achievements : [];
+ progress.badges = Object.assign(progress.badges, saved.badges || {});
+ } catch (err) {
+ console.warn("Unable to load progress state", err);
+ }
+}
-// Maximum achievable points given the targets above
-var PROGRESS_MAX_POINTS = (
- PROGRESS_TARGET_SEARCHES * POINTS_PER_SEARCH + // 50
- PROGRESS_TARGET_VIEWS * POINTS_PER_VIEW + // 100
- PROGRESS_TARGET_CODE_OPENS * POINTS_PER_CODE_OPEN + // 150
- PROGRESS_TARGET_COMPLETIONS * POINTS_PER_COMPLETION // 150
-); // total = 450
+function saveProgressState() {
+ try {
+ progress.bestScore = Math.max(progress.bestScore || 0, progress.points || 0);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
+ } catch (err) {
+ console.warn("Unable to save progress state", err);
+ }
+}
function computeProgressPoints() {
- var raw =
- progress.searches * POINTS_PER_SEARCH +
- progress.projectViews * POINTS_PER_VIEW +
- progress.codeOpens * POINTS_PER_CODE_OPEN +
- progress.completions * POINTS_PER_COMPLETION;
- // Clamp stored points so they never exceed max — prevents aria-valuenow > 100
- progress.points = Math.min(raw, PROGRESS_MAX_POINTS);
+ progress.points = progress.searches * POINTS_PER_SEARCH + progress.projectViews * POINTS_PER_VIEW +
+ progress.codeOpens * POINTS_PER_CODE_OPEN + progress.completions * POINTS_PER_COMPLETION;
}
- // 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) {
- recommendForm.reset();
- resetSkillSelection();
- if (skillsTextInput) skillsTextInput.focus();
- }
- });
- }
+function showAchievementToast(title, detail) {
+ var toast = document.getElementById("achievement-toast");
+ if (!toast) return;
+ toast.textContent = "";
+ var strong = document.createElement("strong");
+ strong.textContent = title;
+ var span = document.createElement("span");
+ span.textContent = detail;
+ toast.appendChild(strong);
+ toast.appendChild(span);
+ toast.classList.add("show");
+ window.clearTimeout(showAchievementToast.timeout);
+ showAchievementToast.timeout = window.setTimeout(function () {
+ toast.classList.remove("show");
+ }, 3200);
+}
- // Also reset skills when the native form reset event fires
- form.addEventListener("reset", function () {
- window.setTimeout(function () {
- resetSkillSelection();
- if (skillsTextInput) skillsTextInput.focus();
- }, 0);
+function addAchievement(title, detail) {
+ if (progress.achievements.some(function (item) { return item.title === title; })) return;
+ progress.achievements.unshift({
+ title: title,
+ description: detail,
+ date: new Date().toLocaleDateString()
});
+ progress.achievements = progress.achievements.slice(0, 5);
+}
+function unlockBadge(id, title, detail) {
+ if (progress.badges[id]) return;
+ progress.badges[id] = true;
+ addAchievement(title, detail);
+ showAchievementToast("Badge unlocked", title + " - " + detail);
+}
- // ----------------------------------------------------------
- // Skill chip manager
- // ----------------------------------------------------------
+function tryUnlockBadges() {
+ if (progress.searches >= 1) unlockBadge("first_search", "First Search", "You used DevPath to find your first project.");
+ if (progress.projectViews >= 1) unlockBadge("project_explorer", "Project Explorer", "You viewed a project detail.");
+ if (progress.codeOpens >= 1) unlockBadge("code_starter", "Code Starter", "You opened starter code.");
+ if (progress.completions >= 1) unlockBadge("completionist", "Completionist", "You marked a project complete.");
+ if (progress.searches >= 5) unlockBadge("roadmap_runner", "Roadmap Runner", "You searched five times.");
+}
+
+function projectIsCompleted(projectId) {
+ return progress.completedProjects.some(function (item) {
+ return (item && typeof item === "object" ? item.id : item) === projectId;
+ });
+}
function updateProfileWidgets() {
var pointsEl = document.getElementById("progress-points");
@@ -149,10 +184,7 @@ function updateProfileWidgets() {
"
Projects Completed" + progress.completions + "";
}
if (meterFill) {
- var percentage = Math.min(
- 100,
- Math.round((progress.points / PROGRESS_MAX_POINTS) * 100)
- );
+ var percentage = Math.min(100, Math.round((progress.points / PROGRESS_MAX_POINTS) * 100));
meterFill.style.width = percentage + "%";
meterFill.setAttribute("aria-valuenow", String(percentage));
meterFill.textContent = percentage + "%";
@@ -165,10 +197,46 @@ function updateProfileWidgets() {
["completionist", "Completionist"],
["roadmap_runner", "Roadmap Runner"]
];
+ badgesEl.innerHTML = badges.map(function (badge) {
+ var unlocked = progress.badges[badge[0]];
+ return "" + (unlocked ? "OK" : "*") + "" + badge[1] + "";
+ }).join("");
+ }
+ if (achievementList) {
+ achievementList.innerHTML = progress.achievements.length
+ ? progress.achievements.map(function (item) {
+ return "" + item.title + "" +
+ item.description + "" + item.date + "";
+ }).join("")
+ : "No achievements yet. Use DevPath and unlock the first badge.";
+ }
+ if (leaderboardList) {
+ var entries = [
+ { name: "Ava", points: 245 },
+ { name: "Kai", points: 192 },
+ { name: "Sam", points: 176 },
+ { name: "You", points: progress.points }
+ ].sort(function (a, b) { return b.points - a.points; });
+ leaderboardList.innerHTML = entries.map(function (entry, index) {
+ return "" + (index + 1) + ". " + entry.name + "" + entry.points + " pts";
+ }).join("");
+ }
+ if (historyList) {
+ historyList.innerHTML = progress.completedProjects.length
+ ? progress.completedProjects.slice(0, 5).map(function (item) {
+ var title = item && typeof item === "object" ? item.title : "Project " + item;
+ return "" + title + "Completed";
+ }).join("")
+ : "No completed projects yet. Mark one complete from a project page.";
+ }
+ if (completionBtn && typeof PROJECT_ID !== "undefined") {
+ var completed = projectIsCompleted(PROJECT_ID);
+ completionBtn.textContent = completed ? "Project Completed" : "Mark Project Complete";
+ completionBtn.disabled = completed;
}
}
-
function recordSearch() {
progress.searches += 1;
computeProgressPoints();
@@ -177,26 +245,42 @@ function recordSearch() {
updateProfileWidgets();
}
- var suggestionsDiv = document.getElementById("skills-suggestions");
- var skillWrap = document.getElementById("skill-input-wrap");
- var visibleSuggestions = [];
- var activeSuggestionIndex = -1;
-
- // Deduplicate available skills (case-insensitive)
- availableSkills = availableSkills.filter(function (skill, index, list) {
- return typeof skill === "string" && skill.trim() &&
- list.findIndex(function (item) {
- return item.toLowerCase() === skill.toLowerCase();
- }) === index;
- });
+function recordProjectView() {
+ if (typeof PROJECT_ID === "undefined") return;
+ if (progress.viewedProjects.indexOf(PROJECT_ID) === -1) {
+ progress.viewedProjects.push(PROJECT_ID);
+ progress.projectViews = progress.viewedProjects.length;
+ computeProgressPoints();
+ tryUnlockBadges();
+ saveProgressState();
+ updateProfileWidgets();
+ }
+}
- if (suggestionsDiv) suggestionsDiv.setAttribute("role", "listbox");
+function recordCodeOpen() {
+ progress.codeOpens += 1;
+ computeProgressPoints();
+ tryUnlockBadges();
+ saveProgressState();
+ updateProfileWidgets();
+}
- function normalizeSkill(skill) { return skill.trim().toLowerCase(); }
+function recordCompletion(projectId, projectTitle) {
+ if (!projectId || projectIsCompleted(projectId)) return;
+ progress.completedProjects.push({ id: projectId, title: projectTitle || "Project " + projectId });
+ progress.completions = progress.completedProjects.length;
+ computeProgressPoints();
+ tryUnlockBadges();
+ saveProgressState();
+ updateProfileWidgets();
+}
loadProgressState();
updateProfileWidgets();
+// ============================================================
+// INDEX PAGE
+// ============================================================
(function initIndexPage() {
var form = document.getElementById("recommend-form");
if (!form) return;
@@ -223,222 +307,108 @@ updateProfileWidgets();
var visibleSuggestions = [];
var SAVED_PROJECTS_KEY = "devpathSavedProjects";
- window.addSkill = function addSkill(rawSkill) {
- var skill = canonicalSkill(rawSkill);
- if (!skill || isSelected(skill)) return;
- selectedSkills.push(skill);
- renderSelectedChips();
- syncSkillsHiddenInput();
- updateQuickPickState();
- clearFieldError("skills-error");
- if (skillsInput) skillsInput.focus();
- };
-
function normalize(value) {
return String(value || "").trim().toLowerCase();
}
- function getCanonicalSkill(rawSkill) {
- var normalizedSkill = normalizeSkill(rawSkill);
- var matched = availableSkills.find(function (s) { return normalizeSkill(s) === normalizedSkill; });
- return matched || rawSkill.trim();
- }
-
- function getFilteredSkills(query) {
- var normalizedQuery = normalizeSkill(query);
- return availableSkills.filter(function (skill) {
- return normalizeSkill(skill).includes(normalizedQuery) && !isSkillSelected(skill);
- }).slice(0, 8);
- }
-
- function renderActiveSuggestion() {
- if (!suggestionsDiv) return;
- suggestionsDiv.querySelectorAll(".suggestion-item").forEach(function (item, index) {
- var isActive = index === activeSuggestionIndex;
- item.classList.toggle("suggestion-item--active", isActive);
- item.setAttribute("aria-selected", isActive ? "true" : "false");
- });
- }
-
- function hideSuggestions() {
- visibleSuggestions = [];
- activeSuggestionIndex = -1;
- if (suggestionsDiv) { suggestionsDiv.style.display = "none"; suggestionsDiv.innerHTML = ""; }
+ function syncSkillsHiddenInput() {
+ skillsHidden.value = JSON.stringify(selectedSkills);
}
- function selectSuggestion(skill) {
- addSkill(skill);
- skillsTextInput.value = "";
- hideSuggestions();
- skillsTextInput.focus();
+ function isSelected(skill) {
+ return selectedSkills.some(function (item) { return normalize(item) === normalize(skill); });
}
- 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(); });
- item.addEventListener("mouseenter", function () { activeSuggestionIndex = index; renderActiveSuggestion(); });
- item.addEventListener("click", function () { selectSuggestion(skill); });
- suggestionsDiv.appendChild(item);
- });
- suggestionsDiv.style.display = "block";
- skillsTextInput.setAttribute("aria-expanded", "true");
+ function canonicalSkill(rawSkill) {
+ var trimmed = String(rawSkill || "").trim();
+ var match = availableSkills.find(function (skill) { return normalize(skill) === normalize(trimmed); });
+ return match || trimmed;
}
function updateQuickPickState() {
quickPickChips.forEach(function (chip) {
- var isActive = isSkillSelected(chip.getAttribute("data-skill") || "");
- chip.classList.toggle("active", isActive);
- chip.setAttribute("aria-pressed", isActive ? "true" : "false");
+ var active = isSelected(chip.getAttribute("data-skill"));
+ chip.classList.toggle("active", active);
+ chip.classList.toggle("selected", active);
+ chip.setAttribute("aria-pressed", active ? "true" : "false");
});
}
- // Add skill on Enter key in the text input
- // 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");
- if (!skill) return;
- if (isSkillSelected(skill)) { removeSkill(skill); } else { addSkill(skill); }
- skillsTextInput.value = "";
- hideSuggestions();
+ function renderSelectedChips() {
+ selectedChips.textContent = "";
+ selectedSkills.forEach(function (skill) {
+ var chip = document.createElement("span");
+ chip.className = "skill-chip-selected";
+ chip.appendChild(document.createTextNode(skill));
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "skill-chip-remove";
+ button.setAttribute("aria-label", "Remove " + skill);
+ button.textContent = "x";
+ button.addEventListener("click", function (event) {
+ event.stopPropagation();
+ removeSkill(skill);
+ });
+ chip.appendChild(button);
+ selectedChips.appendChild(chip);
});
- });
-
- // 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();
- });
+ window.addSkill = function addSkill(rawSkill) {
+ var skill = canonicalSkill(rawSkill);
+ if (!skill || isSelected(skill)) return;
+ selectedSkills.push(skill);
+ renderSelectedChips();
+ syncSkillsHiddenInput();
+ updateQuickPickState();
+ clearFieldError("skills-error");
+ if (skillsInput) skillsInput.focus();
+ };
function removeSkill(skill) {
- // Rebuild the array without the skill that was just removed
- selectedSkills = selectedSkills.filter(function (s) { return normalizeSkill(s) !== normalizeSkill(skill); });
+ selectedSkills = selectedSkills.filter(function (item) { return normalize(item) !== normalize(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.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
- });
+ function clearFieldError(id) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = "";
}
- function syncSkillsHiddenInput() {
- if (!skillsHidden) return;
- // 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(", ");
+ function showFieldError(id, message) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = message;
}
- updateQuickPickState();
+ function clearAllErrors() {
+ ["skills-error", "level-error", "interest-error", "time-error"].forEach(clearFieldError);
+ var general = document.getElementById("form-error-general");
+ if (general) general.textContent = "";
+ }
function hideSuggestions() {
visibleSuggestions = [];
activeSuggestionIndex = -1;
- if (suggestionsDiv) {
- suggestionsDiv.style.display = "none";
- suggestionsDiv.classList.remove("show");
- suggestionsDiv.innerHTML = "";
- }
- syncSuggestionsA11yState();
suggestions.style.display = "none";
suggestions.textContent = "";
skillsInput.setAttribute("aria-expanded", "false");
}
- // ----------------------------------------------------------
- // Form validation
- // ----------------------------------------------------------
-
- //puts error msg under specific field
- function showFieldError(fieldId, message) {
- var el = document.getElementById(fieldId);
- if (el) el.textContent = message;
+ function filteredSkills(query) {
+ var q = normalize(query);
+ if (!q) return [];
+ return availableSkills.filter(function (skill) {
+ return normalize(skill).indexOf(q) !== -1 && !isSelected(skill);
+ }).slice(0, 8);
}
- //clears error msg under specific field
- function clearFieldError(fieldId) {
- var el = document.getElementById(fieldId);
- if (el) el.textContent = ""; //empty string = no error msg
+ function renderSuggestionState() {
+ suggestions.querySelectorAll(".suggestion-item").forEach(function (item, index) {
+ item.classList.toggle("suggestion-item--active", index === activeSuggestionIndex);
+ item.setAttribute("aria-selected", index === activeSuggestionIndex ? "true" : "false");
+ });
}
function showSuggestions(items) {
@@ -452,23 +422,6 @@ updateProfileWidgets();
items.forEach(function (skill, index) {
var item = document.createElement("div");
item.className = "suggestion-item";
-
- // Check if skill is already selected for multi-select styling
- var isSelected = isSkillSelected(skill);
- if (isSelected) {
- item.classList.add("selected");
- }
-
- item.textContent = skill;
- item.setAttribute("role", "option");
- item.setAttribute("id", "skills-suggestion-" + index);
- item.setAttribute("aria-selected", isSelected ? "true" : "false");
-
- // Prevent the input blur handler from closing the menu before click runs.
- item.addEventListener("mousedown", function (evt) {
- evt.preventDefault();
- });
-
item.id = "skills-suggestion-" + index;
item.setAttribute("role", "option");
item.setAttribute("aria-selected", "false");
@@ -479,12 +432,6 @@ updateProfileWidgets();
renderSuggestionState();
});
item.addEventListener("click", function () {
- selectSuggestion(skill);
- // Keep dropdown open if clicking from dropdown (multi-select mode)
- if (suggestionsDiv.classList.contains("show")) {
- displaySuggestions(items);
- skillsTextInput.focus();
- }
window.addSkill(skill);
skillsInput.value = "";
hideSuggestions();
@@ -495,13 +442,9 @@ updateProfileWidgets();
skillsInput.setAttribute("aria-expanded", "true");
}
- // 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()) {
+ if (!selectedSkills.length) {
showFieldError("skills-error", "Please add at least one skill.");
valid = false;
}
@@ -509,50 +452,6 @@ updateProfileWidgets();
showFieldError("level-error", "Please select your experience level.");
valid = false;
}
- });
-
- // 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 = "";
- });
- });
-
- // Multi-select dropdown toggle functionality
- var dropdownBtn = document.getElementById("skills-dropdown-toggle");
- if (dropdownBtn) {
- dropdownBtn.addEventListener("click", function (e) {
- e.preventDefault();
- e.stopPropagation();
- var suggestionsOpen = suggestionsDiv.style.display === "block";
-
- if (suggestionsOpen) {
- hideSuggestions();
- } else {
- // Show all available skills in dropdown
- displaySuggestions(availableSkills);
- suggestionsDiv.classList.add("show");
- }
- });
- }
-
- // Show suggestions on input
- skillsTextInput.addEventListener("input", function (evt) {
- var typedValue = evt.target.value.trim();
- if (typedValue.length === 0) {
- hideSuggestions();
- return;
if (!document.getElementById("interest").value) {
showFieldError("interest-error", "Please select an area of interest.");
valid = false;
@@ -561,371 +460,323 @@ updateProfileWidgets();
showFieldError("time-error", "Please select your time availability.");
valid = false;
}
-
return valid;
}
-
- 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");
- // Ensure the corresponding quick-pick chip is visually active immediately
- try {
- var quickChip = document.querySelector('.skill-chip[data-skill="' + skill + '"]');
- if (quickChip) {
- quickChip.classList.add('active', 'selected');
- quickChip.setAttribute('aria-pressed', 'true');
- }
- } catch (e) {
- // ignore DOM errors
- }
- // Keep focus in the input so user can continue typing
- if (skillsTextInput) skillsTextInput.focus();
- }
-
- // 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();
- // Also clear the visual active state on the quick-pick chip if present
- try {
- var quickChip = document.querySelector('.skill-chip[data-skill="' + skill + '"]');
- if (quickChip) {
- quickChip.classList.remove('active', 'selected');
- quickChip.setAttribute('aria-pressed', 'false');
- }
- } catch (e) {
- // ignore DOM errors
- }
- }
-
- // 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.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
- });
- }
-
- function syncSkillsHiddenInput() {
- if (!skillsHidden) {
- var skillsHidden = document.getElementById("skills");
- }
- }
-
- updateQuickPickState();
-
-
// ----------------------------------------------------------
- // Form validation
+ // Loading state
// ----------------------------------------------------------
- //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
- }
-
- //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;
- }
-
- return valid;
- }
-
-
-
- // ----------------------------------------------------------
- // Form submission and API call
- // ----------------------------------------------------------
-
- // 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);
+ submitBtn.setAttribute("aria-busy", isLoading ? "true" : "false");
btnLabel.style.display = isLoading ? "none" : "inline";
btnLoading.style.display = isLoading ? "inline-flex" : "none";
-
if (isLoading) {
- resultsGrid.innerHTML = "";
+ resultsSection.style.display = "block";
+ resultsLoadingEl.style.display = "block";
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "none";
- resultsEmptyEl.textContent = "";
- resultsLoadingEl.style.display = "block";
- resultsSection.style.display = "block";
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) { // if no projects returned from api, show "no results" and hide the grid
- resultsGrid.style.display = "none";
- resultsEmptyEl.style.display = "block";
- if (message && emptyMessageEl) emptyMessageEl.textContent = message;
- 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" });
- }
-
function truncate(text, maxLength) {
- text = text || "";
+ if (!text) return "";
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.className = "project-tag project-tag--" + type;
span.textContent = text;
return span;
}
- //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
+ // Renders project result cards or shows the empty-state message.
function renderResults(projects, message) {
- console.log("Rendering results with projects:", projects);
- console.log("Message:", message);
-
resultsSection.style.display = "block";
resultsLoadingEl.style.display = "none";
- // Clear out any cards from a previous search before showing new ones
resultsGrid.innerHTML = "";
- recordSearch();
- if (!projects || projects.length === 0) {
- resultsGrid.style.display = "none";
- resultsEmptyEl.style.display = "block";
-
- // Show a friendly custom message when the user selected an interest
- var selectedInterest = document.getElementById("interest")?.value;
- if (selectedInterest) {
- emptyMessageEl.textContent = "No projects are currently available for this interest. Please check back later or try a different area.";
- } else if (message) {
- emptyMessageEl.textContent = message;
- } else {
- emptyMessageEl.textContent = "Try adjusting your skills or choosing a different interest area.";
- }
+ var shareWrap = document.getElementById("share-result-wrap");
+ var hasResults = projects && projects.length > 0;
- // Clear out previous results before rendering new ones
- resultsGrid.innerHTML = "";
+ // 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 no projects are returned, show the empty state message
- if (!projects || projects.length === 0) {
- resultsGrid.style.display = "none";
- resultsEmptyEl.style.display = "block";
+ if (!hasResults) {
+ if (emptyMessageEl) { if (message) 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));
});
- recordSearch();
resultsSection.scrollIntoView({ behavior: "smooth" });
}
function buildProjectCard(project) {
-
var card = document.createElement("div");
card.className = "project-card";
- // Console logging for debugging
- console.log("Building card for project:", project);
- console.log("Project ID:", project.id);
-
- // 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";
-
- // 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"
- tagsRow.appendChild(createTag(project.level, "level " + (project.level || "").toLowerCase()));
+ var descText = document.createElement("span");
+ descText.className = "project-card-desc-text";
+ descText.textContent = truncate(project.description, 120);
+ desc.appendChild(descText);
+
+ if (project.description && project.description.length > 120) {
+ var expanded = false;
+ var readMore = document.createElement("button");
+ readMore.type = "button";
+ readMore.className = "read-more-btn";
+ readMore.textContent = "Read more";
+ readMore.setAttribute("aria-expanded", "false");
+ readMore.addEventListener("click", function () {
+ expanded = !expanded;
+ descText.textContent = expanded ? project.description : truncate(project.description, 120);
+ readMore.textContent = expanded ? "Read less" : "Read more";
+ readMore.setAttribute("aria-expanded", expanded ? "true" : "false");
+ });
+ desc.appendChild(readMore);
+ }
- // Time tag
- tagsRow.appendChild(createTag("Time: " + project.time, "time"));
+ var tags = document.createElement("div");
+ tags.className = "project-card-tags";
+ (project.skills || []).forEach(function (skill) { tags.appendChild(createTag(skill, "skill")); });
+ tags.appendChild(createTag(project.level, project.level));
+ tags.appendChild(createTag("Time: " + project.time, "time"));
- // Footer with view-details link
var footer = document.createElement("div");
footer.className = "project-card-footer";
+ if (typeof DevPathBookmarks !== "undefined") {
+ var saveBtn = document.createElement("button");
+ saveBtn.type = "button";
+ saveBtn.className = "btn-save-project";
+ saveBtn.setAttribute("data-save-project-id", project.id);
+ var isSaved = DevPathBookmarks.isSaved(project.id);
+ if (isSaved) saveBtn.classList.add("saved");
+ saveBtn.setAttribute("aria-pressed", isSaved ? "true" : "false");
+ DevPathBookmarks.setButtonContent(saveBtn, isSaved);
+ saveBtn.addEventListener("click", function () {
+ DevPathBookmarks.toggle(project, saveBtn);
+ });
+ footer.appendChild(saveBtn);
+ }
+
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
-
- console.log("Created link with href:", link.href);
-
link.href = "/project/" + project.id;
- footer.appendChild(saveButton);
footer.appendChild(link);
- // Assemble the card in order
card.appendChild(title);
card.appendChild(desc);
- card.appendChild(tagsRow);
+ card.appendChild(tags);
card.appendChild(footer);
return card;
}
- function runProjectSearch(query) {
- if (!query) return;
- setLoadingState(true);
- fetch("/api/search?q=" + encodeURIComponent(query))
- .then(function (response) {
- return response.json().then(function (data) {
- if (!response.ok) throw new Error("Search failed. Please try again.");
- return data;
+
+ // ----------------------------------------------------------
+ // 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 truncatedFlag = 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);
+ truncatedFlag = 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();
+ truncatedFlag = true;
+ params.set("skills", skillsArr.join(", "));
+ url = baseUrl + "?" + params.toString();
+ }
+
+ return { url: url, truncated: truncatedFlag };
+ }
+
+ 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.
+ 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);
});
- })
- .then(function (projects) {
- setLoadingState(false);
- recordSearch();
- var message = projects.length
- ? null
- : "No projects matched \"" + query + "\". Try a different keyword.";
- renderResults(projects, message);
- var mobileMenu = document.getElementById("nav-mobile-menu");
- var mobileToggle = document.getElementById("nav-mobile-toggle");
- if (mobileMenu && mobileMenu.classList.contains("open")) {
- mobileMenu.classList.remove("open");
- if (mobileToggle) {
- mobileToggle.classList.remove("open");
- mobileToggle.setAttribute("aria-expanded", "false");
- }
- }
- })
- .catch(function (err) {
- setLoadingState(false);
- var general = document.getElementById("form-error-general");
- if (general) general.textContent = err.message || "Search failed. Please try again.";
- });
+ } else {
+ fallbackShareCopy(url);
+ }
+ });
}
- return card;
+
+ // ----------------------------------------------------------
+ // 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 "";
}
- bindSearchForm(document.getElementById("topic-search-form"), document.getElementById("topic-search"));
- bindSearchForm(document.getElementById("topic-search-form-mobile"), document.getElementById("topic-search-mobile"));
+ // ----------------------------------------------------------
+ // Auto-fill from shared URL query params (no auto-submit)
+ // ----------------------------------------------------------
- skillsInput.setAttribute("role", "combobox");
- skillsInput.setAttribute("aria-expanded", "false");
- suggestions.setAttribute("role", "listbox");
+ // 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) window.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" });
+ }
+ })();
+
+
+ // ----------------------------------------------------------
+ // Skill input event listeners
+ // ----------------------------------------------------------
skillsInput.addEventListener("input", function () {
showSuggestions(filteredSkills(skillsInput.value));
@@ -947,7 +798,6 @@ updateProfileWidgets();
renderSuggestionState();
return;
}
-
if (event.key === "Escape") {
hideSuggestions();
return;
@@ -978,261 +828,333 @@ updateProfileWidgets();
skillWrap.addEventListener("click", function () { skillsInput.focus(); });
}
- 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;
+ var clearBtn = document.getElementById("clear-filters-btn");
+ if (clearBtn) {
+ clearBtn.addEventListener("click", function () {
+ form.reset();
+ selectedSkills = [];
+ renderSelectedChips();
+ syncSkillsHiddenInput();
+ updateQuickPickState();
+ clearAllErrors();
+ hideSuggestions();
+ resultsSection.style.display = "none";
+ skillsInput.focus();
+ });
+ }
+
+ var resetProgressBtn = document.getElementById("reset-progress-btn");
+ if (resetProgressBtn) {
+ resetProgressBtn.addEventListener("click", function () {
+ progress.searches = 0;
+ progress.projectViews = 0;
+ progress.codeOpens = 0;
+ progress.completions = 0;
+ progress.points = 0;
+ progress.viewedProjects = [];
+ progress.completedProjects = [];
+ progress.achievements = [];
+ progress.badges = {
+ first_search: false,
+ project_explorer: false,
+ code_starter: false,
+ completionist: false,
+ roadmap_runner: false
+ };
+ saveProgressState();
+ updateProfileWidgets();
+ showAchievementToast("Progress reset", "Your local profile has been cleared.");
+ });
+ }
+
+ // ----------------------------------------------------------
+ // Form submission and API call
+ // ----------------------------------------------------------
+
+ form.addEventListener("submit", function (event) {
+ event.preventDefault();
+ clearAllErrors();
+ if (skillsInput.value.trim()) {
+ window.addSkill(skillsInput.value);
+ skillsInput.value = "";
+ hideSuggestions();
+ }
+ if (!validateForm()) return;
+ setLoadingState(true);
+ fetch("/api/recommend", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ skills: JSON.stringify(selectedSkills),
+ level: document.getElementById("level").value,
+ interest: document.getElementById("interest").value,
+ time: document.getElementById("time").value
+ })
+ })
+ .then(function (response) {
+ return response.json().then(function (data) {
+ if (!response.ok) throw new Error(data.error || "Unable to generate recommendations.");
+ return data;
+ });
+ })
+ .then(function (data) {
+ setLoadingState(false);
+ recordSearch();
+ renderResults(data.projects || [], data.message);
+ })
+ .catch(function (err) {
+ setLoadingState(false);
+ var general = document.getElementById("form-error-general");
+ if (general) general.textContent = err.message || "An unexpected error occurred. Please try again.";
+ });
+ });
+
+ // ----------------------------------------------------------
+ // GitHub modal
+ // ----------------------------------------------------------
+
+ 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");
+
+ function closeGithubModal() {
+ modal.classList.remove("active");
+ githubInput.value = "";
+ errorMsg.textContent = "";
}
-} // end isIndexPage
+ if (modal && openModalBtn && closeModalBtn && fetchBtn && githubInput && errorMsg) {
+ openModalBtn.addEventListener("click", function () {
+ modal.classList.add("active");
+ githubInput.focus();
+ });
+ closeModalBtn.addEventListener("click", closeGithubModal);
+ modal.addEventListener("click", function (event) {
+ if (event.target === modal) closeGithubModal();
+ });
+ fetchBtn.addEventListener("click", function (event) {
+ event.preventDefault();
+ var username = githubInput.value.trim();
+ errorMsg.textContent = "";
+ if (!username) {
+ errorMsg.textContent = "Please enter a GitHub username.";
+ return;
+ }
+ fetchBtn.disabled = true;
+ fetchBtn.textContent = "Syncing...";
+ fetch("https://api.github.com/users/" + encodeURIComponent(username) + "/repos?sort=updated&per_page=100")
+ .then(function (response) {
+ if (!response.ok) throw new Error(response.status === 404 ? "Username not found." : "Unable to fetch GitHub repositories.");
+ return response.json();
+ })
+ .then(function (repos) {
+ var languages = [];
+ repos.forEach(function (repo) {
+ if (repo.language && languages.indexOf(repo.language) === -1) languages.push(repo.language);
+ });
+ if (!languages.length) {
+ errorMsg.textContent = "No public languages found.";
+ return;
+ }
+ languages.forEach(window.addSkill);
+ closeGithubModal();
+ })
+ .catch(function (err) {
+ if (err.message && err.message.toLowerCase().indexOf("networkerror") !== -1 || err.name === "TypeError") {
+ errorMsg.textContent = "Network error: Connection blocked or offline. Please disable adblockers or check your connection.";
+ } else {
+ errorMsg.textContent = err.message || "Failed to fetch skills.";
+ }
+ fetchBtn.disabled = false;
+ fetchBtn.textContent = "Fetch Skills";
+ });
+ });
+ }
+})();
// ============================================================
// DETAIL PAGE
// ============================================================
-if (isDetailPage) {
+(function initDetailPage() {
+ if (typeof PROJECT_ID === "undefined") return;
+ recordProjectView();
+
+ 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 btnCopyCode = document.getElementById("btn-copy-code");
+ var copyToast = document.getElementById("copy-toast");
+ var completionBtn = document.getElementById("btn-mark-complete");
+ var codeFetched = false;
- 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
+ function renderCode(code) {
+ codeContentEl.textContent = "";
+ String(code || "").split("\n").forEach(function (line, index) {
+ var row = document.createElement("div");
+ row.className = "code-line";
+ var number = document.createElement("span");
+ number.className = "code-line-number";
+ number.setAttribute("aria-hidden", "true");
+ number.textContent = index + 1;
+ var content = document.createElement("span");
+ content.className = "code-line-content";
+ content.textContent = line;
+ row.appendChild(number);
+ row.appendChild(content);
+ codeContentEl.appendChild(row);
+ });
+ }
- // Cache flag so code is only fetched once per page load
- var codeFetched = false;
+ function fetchStarterCode() {
+ codeContentEl.textContent = "Loading starter code...";
+ fetch("/project/" + PROJECT_ID + "/code")
+ .then(function (response) {
+ return response.json().then(function (data) {
+ if (!response.ok) throw new Error(data.error || "Starter code unavailable.");
+ return data;
+ });
+ })
+ .then(function (data) {
+ codePanelFilename.textContent = data.filename;
+ renderCode(data.code);
+ codeFetched = true;
+ })
+ .catch(function (err) {
+ codeContentEl.textContent = err.message || "Could not load starter code. Try downloading it instead.";
+ });
+ }
- //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
+ recordCodeOpen();
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 (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 () {
- if (codeContentEl) {
- codeContentEl.textContent = "Could not load starter code. Try downloading it instead.";
- }
- });
- }
-
- // Attach open/close handlers
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
- }
-
- // 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 (codePanelOverlay) codePanelOverlay.addEventListener("click", closeCodePanel);
+ document.addEventListener("keydown", function (event) {
+ if (event.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;
-
- //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 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");
- }
-
- // 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);
- }
-
if (btnCopyCode) {
btnCopyCode.addEventListener("click", function () {
- var code = codeContentEl
- ? Array.from(codeContentEl.querySelectorAll(".line-content"))
- .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
+ var code = Array.prototype.slice.call(codeContentEl.querySelectorAll(".code-line-content"))
+ .map(function (line) { return line.textContent; })
+ .join("\n");
+ if (!code) return;
+ var done = function () {
+ if (copyToast) {
+ copyToast.classList.add("show");
+ window.setTimeout(function () { copyToast.classList.remove("show"); }, 2500);
+ }
+ };
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(done);
} else {
- fallbackCopy(code); // Clipboard API not supported, use fallback method
+ var textarea = document.createElement("textarea");
+ textarea.value = code;
+ textarea.style.cssText = "position:fixed;top:-9999px;left:-9999px";
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ try { document.execCommand("copy"); } catch (err) {}
+ document.body.removeChild(textarea);
+ done();
}
});
- } // end github modal handlers
-
- /* ---- Scroll-to-top button ---- */
-
- var SCROLL_THRESHOLD = 300;
- var scrollTopBtn = document.getElementById('scroll-top-btn');
-
- function handleScroll() {
- if (!scrollTopBtn) return;
- if (window.pageYOffset > SCROLL_THRESHOLD) {
- scrollTopBtn.classList.add('visible');
- } else {
- scrollTopBtn.classList.remove('visible');
- }
- }
-
- function scrollToTop() {
- window.scrollTo({ top: 0, behavior: 'smooth' });
}
- if (scrollTopBtn) {
- window.addEventListener('scroll', handleScroll);
- scrollTopBtn.addEventListener('click', scrollToTop);
- }
+ var roadmapCheckboxes = Array.prototype.slice.call(document.querySelectorAll(".roadmap-checkbox"));
+ var progressFill = document.getElementById("roadmap-progress-fill");
+ var progressText = document.getElementById("roadmap-progress-text");
+ var progressBar = document.querySelector(".roadmap-progress-bar");
+ var roadmapStorageKey = "devpath-roadmap-progress-" + PROJECT_ID;
+
+ function updateRoadmapProgress() {
+ if (!roadmapCheckboxes.length) return;
+ var completed = roadmapCheckboxes.filter(function (checkbox) { return checkbox.checked; }).length;
+ var percent = Math.round((completed / roadmapCheckboxes.length) * 100);
+ roadmapCheckboxes.forEach(function (checkbox) {
+ var step = checkbox.closest(".roadmap-step");
+ if (step) step.classList.toggle("completed", checkbox.checked);
+ });
+ if (progressFill) progressFill.style.width = percent + "%";
+ if (progressText) progressText.textContent = percent + "% completed";
+ if (progressBar) progressBar.setAttribute("aria-valuenow", String(percent));
+ try {
+ localStorage.setItem(roadmapStorageKey, JSON.stringify(roadmapCheckboxes.map(function (checkbox) {
+ return checkbox.checked;
+ })));
+ } catch (err) {}
}
- // 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();
+ try {
+ var saved = JSON.parse(localStorage.getItem(roadmapStorageKey) || "[]");
+ roadmapCheckboxes.forEach(function (checkbox, index) {
+ checkbox.checked = !!saved[index];
+ });
+ } catch (err) {}
+ roadmapCheckboxes.forEach(function (checkbox) {
+ checkbox.addEventListener("change", updateRoadmapProgress);
});
+ updateRoadmapProgress();
- // 2. Close Github Input Modal
- const closeGithubModal = () => {
- modal.classList.remove('active');
- githubInput.value = '';
- errorMsg.textContent = '';
- };
+ if (completionBtn) {
+ completionBtn.addEventListener("click", function () {
+ recordCompletion(PROJECT_ID, typeof PROJECT_TITLE !== "undefined" ? PROJECT_TITLE : "");
+ showAchievementToast("Project completed", "Nice work finishing this project.");
+ });
+ }
+})();
- closeModalBtn.addEventListener('click', closeGithubModal);
- // Close on clicking outside the card
- modal.addEventListener('click', (e) => {
- if (e.target === modal) closeGithubModal();
- });
+// ============================================================
+// Scroll-to-top / scroll-to-bottom button
+// ============================================================
+(function initScrollButton() {
+ var button = document.getElementById("scroll-top-btn");
+ var icon = document.getElementById("scroll-btn-icon");
+ if (!button) return;
+ var atBottom = false;
+
+ function nearBottom() {
+ return window.innerHeight + window.pageYOffset >= document.body.scrollHeight - 40;
+ }
- // 3. Fetch Skills Logic
- fetchBtn.addEventListener('click', async () => {
- const username = githubInput.value.trim();
- if (!username) return;
+ function update() {
+ button.classList.toggle("visible", window.pageYOffset > 200);
+ atBottom = nearBottom();
+ button.setAttribute("aria-label", atBottom ? "Scroll to top" : "Scroll to bottom");
+ button.title = atBottom ? "Scroll to top" : "Scroll to bottom";
+ if (icon) icon.innerHTML = atBottom ? '' : '';
+ }
- 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';
- }
+ window.addEventListener("scroll", update, { passive: true });
+ button.addEventListener("click", function () {
+ window.scrollTo({ top: atBottom ? 0 : document.body.scrollHeight, behavior: "smooth" });
});
update();
})();
-
(function initScrollSpy() {
var sections = document.querySelectorAll("section[id], header[id]");
var navLinks = document.querySelectorAll(".nav-link, .nav-mobile-link");
@@ -1241,22 +1163,19 @@ if (
var observerOptions = {
root: null,
- rootMargin: "-20% 0px -70% 0px",
+ rootMargin: "0px 0px -50% 0px",
threshold: 0
};
- if (scrollTopBtn) {
- window.addEventListener('scroll', handleScroll, { passive: true });
- scrollTopBtn.addEventListener('click', function () {
- if (atBottom) {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- } else {
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
+ var observer = new IntersectionObserver(function(entries) {
+ entries.forEach(function(entry) {
+ if (entry.isIntersecting) {
+ navLinks.forEach(function(link) {
+ link.classList.toggle('active', link.getAttribute('href') === '#' + entry.target.id);
+ });
}
});
- handleScroll();
- }
-}());
+ }, observerOptions);
sections.forEach(function (sec) {
observer.observe(sec);
diff --git a/static/style.css b/static/style.css
index 1cbf860..b578f91 100644
--- a/static/style.css
+++ b/static/style.css
@@ -5022,3 +5022,123 @@ body.dark-theme .theme-toggle:hover {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
}
+
+
+/* ---- Share Result button ---- */
+.share-result-wrap {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 1rem;
+}
+
+.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;
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
+}
+
+.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);
+}
+
+
+
+/* ---- 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 cf11844..4265d16 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -8,6 +8,35 @@
content="DevPath recommends real coding projects based on your skills, level, and interests — with full roadmaps and starter code." />
DevPath — Find Projects Based On Your Skills
+
+
@@ -42,65 +71,6 @@