diff --git a/static/script.js b/static/script.js
index 577a5c2..a69113b 100644
--- a/static/script.js
+++ b/static/script.js
@@ -1,557 +1,381 @@
-// DevPath client-side behavior.
-
-(function () {
- var html = document.documentElement;
-
- 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 = "light";
- try {
- theme = localStorage.getItem("theme") || html.getAttribute("data-theme") || "light";
- } catch (err) {
- 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");
- });
-
- initTheme();
-})();
-
+// 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)
+
+// ============================================================
+// 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');
+
+
+// ============================================================
+// Mobile navigation toggle (runs on all pages)
+// ============================================================
(function initMobileNav() {
- var toggle = document.getElementById("nav-mobile-toggle");
- var menu = document.getElementById("nav-mobile-menu");
- if (!toggle || !menu) return;
+ var toggle = document.getElementById("nav-mobile-toggle"); //hamburger button
+ var menu = document.getElementById("nav-mobile-menu"); //dropdown menu
- function setOpen(isOpen) {
- menu.classList.toggle("open", isOpen);
- toggle.classList.toggle("open", isOpen);
- toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
- }
+ // Nothing to do if the nav isn't on this page, just bail out
+ if (!toggle || !menu) return;
toggle.addEventListener("click", function () {
- setOpen(!menu.classList.contains("open"));
+ // classList.toggle returns true if class was added, false if removed
+ var isOpen = menu.classList.toggle("open");
+ toggle.classList.toggle("open", isOpen);
+ // Keep aria-expanded in sync so screen readers know if menu is open or closed
+ toggle.setAttribute("aria-expanded", isOpen);
});
- menu.querySelectorAll(".nav-mobile-link").forEach(function (link) {
- link.addEventListener("click", function () {
- setOpen(false);
+ // 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");
});
});
-
- window.addEventListener("resize", function () {
- if (window.innerWidth >= 640) setOpen(false);
- });
})();
-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);
- }
-}
-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);
- }
-}
+// ============================================================
+// 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 = [];
-function computeProgressPoints() {
- progress.points = progress.searches * 5 + progress.projectViews * 10 +
- progress.codeOpens * 15 + progress.completions * 30;
-}
+ // ----------------------------------------------------------
+ // Reset skill selection (used by Clear Filters and form reset)
+ // ----------------------------------------------------------
+ function resetSkillSelection() {
+ selectedSkills = [];
+ if (skillsHidden) skillsHidden.value = "";
+ if (chipsSelectedEl) chipsSelectedEl.innerHTML = "";
+ if (skillsTextInput) skillsTextInput.value = "";
+ if (suggestionsDiv) { suggestionsDiv.innerHTML = ""; suggestionsDiv.style.display = "none"; }
+ if (quickPickChips) {
+ quickPickChips.forEach(function (chip) {
+ chip.classList.remove("active", "selected");
+ chip.setAttribute("aria-pressed", "false");
+ });
+ }
+ clearFieldError("skills-error");
+ }
-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);
-}
+ // 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 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()
+ // Also reset skills when the native form reset event fires
+ form.addEventListener("reset", function () {
+ window.setTimeout(function () {
+ resetSkillSelection();
+ if (skillsTextInput) skillsTextInput.focus();
+ }, 0);
});
- 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);
-}
-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;
- });
-}
+ // ----------------------------------------------------------
+ // Skill chip manager
+ // ----------------------------------------------------------
-function updateProfileWidgets() {
- var pointsEl = document.getElementById("progress-points");
- var statsEl = document.getElementById("progress-stats");
- var meterFill = document.getElementById("progress-meter-fill");
- var badgesEl = document.getElementById("progress-badges");
- var achievementList = document.getElementById("achievement-list");
- var leaderboardList = document.getElementById("leaderboard-list");
- var historyList = document.getElementById("completed-history-list");
- var completionBtn = document.getElementById("btn-mark-complete");
-
- if (pointsEl) pointsEl.textContent = progress.points;
- if (statsEl) {
- statsEl.innerHTML =
- "
Searches" + progress.searches + "" +
- "Projects Viewed" + progress.projectViews + "" +
- "Code Opens" + progress.codeOpens + "" +
- "Projects Completed" + progress.completions + "";
- }
- if (meterFill) {
- var percentage = Math.min(100, Math.round((progress.points / 250) * 100));
- meterFill.style.width = percentage + "%";
- meterFill.setAttribute("aria-valuenow", String(percentage));
- meterFill.textContent = percentage + "%";
- }
- if (badgesEl) {
- var badges = [
- ["first_search", "First Search"],
- ["project_explorer", "Project Explorer"],
- ["code_starter", "Code Starter"],
- ["completionist", "Completionist"],
- ["roadmap_runner", "Roadmap Runner"]
+ // Skills list for autocomplete (from skills.js)
+ var availableSkills = [];
+ if (typeof skills !== "undefined" && Array.isArray(skills) && skills.length > 0) {
+ availableSkills = skills.map(function (s) { return s.label; });
+ } else {
+ // Fallback if skills.js doesn't load
+ availableSkills = [
+ "Python", "JavaScript", "Java", "C++", "HTML", "CSS", "React", "Node.js",
+ "Django", "Flask", "SQL", "MongoDB", "AWS", "Docker", "Kubernetes", "Git",
+ "C#", "Ruby", "PHP", "Go", "Swift", "TypeScript", "Angular", "Vue.js",
+ "Spring", "Flutter", "TensorFlow", "PyTorch", "Data Science",
+ "Machine Learning", "Artificial Intelligence", "DevOps", "Cybersecurity",
+ "Blockchain", "UI/UX Design", "Game Development", "CI/CD", "REST API", "GraphQL",
+ "Rust", "Kotlin"
];
- 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();
- tryUnlockBadges();
- saveProgressState();
- updateProfileWidgets();
-}
+ var suggestionsDiv = document.getElementById("skills-suggestions");
+ var skillWrap = document.getElementById("skill-input-wrap");
+ var visibleSuggestions = [];
+ var activeSuggestionIndex = -1;
-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();
- }
-}
+ // 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 recordCodeOpen() {
- progress.codeOpens += 1;
- computeProgressPoints();
- tryUnlockBadges();
- saveProgressState();
- updateProfileWidgets();
-}
+ if (suggestionsDiv) suggestionsDiv.setAttribute("role", "listbox");
-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();
-}
+ function normalizeSkill(skill) { return skill.trim().toLowerCase(); }
-loadProgressState();
-updateProfileWidgets();
-
-(function initIndexPage() {
- var form = document.getElementById("recommend-form");
- if (!form) return;
-
- var submitBtn = document.getElementById("submit-btn");
- var btnLabel = document.getElementById("btn-label");
- var btnLoading = document.getElementById("btn-loading");
- var resultsSection = document.getElementById("results-section");
- var resultsGrid = document.getElementById("results-grid");
- var resultsLoadingEl = document.getElementById("results-loading");
- var resultsEmptyEl = document.getElementById("results-empty");
- var emptyMessageEl = document.getElementById("empty-message");
- var skillsHidden = document.getElementById("skills");
- var skillsInput = document.getElementById("skills-input");
- var selectedChips = document.getElementById("skill-chips-selected");
- var suggestions = document.getElementById("skills-suggestions");
- var skillWrap = document.getElementById("skill-input-wrap");
- var quickPickChips = Array.prototype.slice.call(document.querySelectorAll(".skill-chip"));
- var selectedSkills = [];
- var availableSkills = (typeof skills !== "undefined" && Array.isArray(skills))
- ? skills.map(function (item) { return item.label; }).filter(Boolean)
- : quickPickChips.map(function (chip) { return chip.getAttribute("data-skill"); });
- var activeSuggestionIndex = -1;
- var visibleSuggestions = [];
- var SAVED_PROJECTS_KEY = "devpathSavedProjects";
-
- function normalize(value) {
- return String(value || "").trim().toLowerCase();
+ function isSkillSelected(skill) {
+ var normalizedSkill = normalizeSkill(skill);
+ return selectedSkills.some(function (s) { return normalizeSkill(s) === normalizedSkill; });
}
- function getSavedProjects() {
- try {
- var saved = JSON.parse(localStorage.getItem(SAVED_PROJECTS_KEY) || "[]");
- return Array.isArray(saved) ? saved : [];
- } catch (err) {
- console.warn("Unable to load saved projects", err);
- return [];
- }
+ function getCanonicalSkill(rawSkill) {
+ var normalizedSkill = normalizeSkill(rawSkill);
+ var matched = availableSkills.find(function (s) { return normalizeSkill(s) === normalizedSkill; });
+ return matched || rawSkill.trim();
}
- function saveSavedProjects(projects) {
- try {
- localStorage.setItem(SAVED_PROJECTS_KEY, JSON.stringify(projects));
- } catch (err) {
- console.warn("Unable to save projects", err);
- }
+ function getFilteredSkills(query) {
+ var normalizedQuery = normalizeSkill(query);
+ return availableSkills.filter(function (skill) {
+ return normalizeSkill(skill).includes(normalizedQuery) && !isSkillSelected(skill);
+ }).slice(0, 8);
}
- function projectIsSaved(projectId) {
- return getSavedProjects().some(function (project) {
- return String(project.id) === String(projectId);
+ 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 saveProject(project) {
- var saved = getSavedProjects();
- if (saved.some(function (item) { return String(item.id) === String(project.id); })) return;
+ function hideSuggestions() {
+ visibleSuggestions = [];
+ activeSuggestionIndex = -1;
+ if (suggestionsDiv) { suggestionsDiv.style.display = "none"; suggestionsDiv.innerHTML = ""; }
+ }
- saved.unshift({
- id: project.id,
- title: project.title,
- level: project.level || "",
- time: project.time || "",
- skills: Array.isArray(project.skills) ? project.skills.slice(0, 4) : []
- });
- saveSavedProjects(saved);
- renderSavedProjects();
+ function selectSuggestion(skill) {
+ addSkill(skill);
+ skillsTextInput.value = "";
+ hideSuggestions();
+ skillsTextInput.focus();
}
- function removeSavedProject(projectId) {
- var saved = getSavedProjects().filter(function (project) {
- return String(project.id) !== String(projectId);
- });
- saveSavedProjects(saved);
- renderSavedProjects();
- document.querySelectorAll("[data-save-project-id='" + projectId + "']").forEach(function (button) {
- button.classList.remove("saved");
- button.textContent = "Save Project";
- button.setAttribute("aria-pressed", "false");
+ 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 toggleSavedProject(project, button) {
- if (projectIsSaved(project.id)) {
- removeSavedProject(project.id);
- return;
- }
-
- saveProject(project);
- button.classList.add("saved");
- button.textContent = "Saved";
- button.setAttribute("aria-pressed", "true");
+ 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");
+ });
}
- function renderSavedProjects() {
- var list = document.getElementById("saved-projects-list");
- var count = document.getElementById("saved-projects-count");
- if (!list || !count) return;
-
- var saved = getSavedProjects();
- count.textContent = saved.length + " saved";
- list.textContent = "";
-
- if (!saved.length) {
- var empty = document.createElement("p");
- empty.className = "saved-projects-empty";
- empty.textContent = "No saved projects yet.";
- list.appendChild(empty);
+ // 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();
+ }
+ });
- saved.forEach(function (project) {
- var item = document.createElement("article");
- item.className = "saved-project-item";
-
- var title = document.createElement("a");
- title.href = "/project/" + project.id;
- title.textContent = project.title;
-
- var meta = document.createElement("span");
- meta.textContent = [project.level, project.time].filter(Boolean).join(" - ");
-
- var remove = document.createElement("button");
- remove.type = "button";
- remove.className = "saved-project-remove";
- remove.textContent = "Remove";
- remove.addEventListener("click", function () {
- removeSavedProject(project.id);
- });
-
- item.appendChild(title);
- item.appendChild(meta);
- item.appendChild(remove);
- list.appendChild(item);
+ // 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 syncSkillsHiddenInput() {
- skillsHidden.value = JSON.stringify(selectedSkills);
- }
+ // Show suggestions on input
+ skillsTextInput.addEventListener("input", function (evt) {
+ var typedValue = evt.target.value.trim();
+ if (typedValue.length === 0) { hideSuggestions(); return; }
+ displaySuggestions(getFilteredSkills(typedValue));
+ });
- function isSelected(skill) {
- return selectedSkills.some(function (item) { return normalize(item) === normalize(skill); });
- }
+ skillsTextInput.addEventListener("focus", function () {
+ if (skillsTextInput.value.trim()) displaySuggestions(getFilteredSkills(skillsTextInput.value));
+ });
- function canonicalSkill(rawSkill) {
- var trimmed = String(rawSkill || "").trim();
- var match = availableSkills.find(function (skill) { return normalize(skill) === normalize(trimmed); });
- return match || trimmed;
- }
+ // Hide suggestions when input loses focus
+ skillsTextInput.addEventListener("blur", function () {
+ setTimeout(function () { hideSuggestions(); }, 150);
+ });
- function updateQuickPickState() {
- quickPickChips.forEach(function (chip) {
- var active = isSelected(chip.getAttribute("data-skill"));
- chip.classList.toggle("active", active);
- chip.classList.toggle("selected", active);
- chip.setAttribute("aria-pressed", active ? "true" : "false");
- });
+ if (skillWrap) {
+ skillWrap.addEventListener("click", function () { skillsTextInput.focus(); });
}
- 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);
- });
- }
+ 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;
+ //add a skill to the list if it's not empty or a duplicate
+ function addSkill(rawSkill) {
+ var skill = getCanonicalSkill(rawSkill);
+ if (!skill) return;
+ 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");
- if (skillsInput) skillsInput.focus();
- };
+ }
+ // remove a skill from the list and update the UI accordingly
function removeSkill(skill) {
- selectedSkills = selectedSkills.filter(function (item) { return normalize(item) !== normalize(skill); });
+ // Rebuild the array without the skill that was just removed
+ selectedSkills = selectedSkills.filter(function (s) { return normalizeSkill(s) !== normalizeSkill(skill); });
renderSelectedChips();
syncSkillsHiddenInput();
updateQuickPickState();
}
- function clearFieldError(id) {
- var el = document.getElementById(id);
- if (el) el.textContent = "";
+ // 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){
- skillsHidden = document.getElementById("skills");
- }
+ 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 clearAllErrors() {
- ["skills-error", "level-error", "interest-error", "time-error"].forEach(clearFieldError);
- var general = document.getElementById("form-error-general");
- if (general) general.textContent = "";
- }
+ updateQuickPickState();
- function hideSuggestions() {
- visibleSuggestions = [];
- activeSuggestionIndex = -1;
- suggestions.style.display = "none";
- suggestions.textContent = "";
- skillsInput.setAttribute("aria-expanded", "false");
- }
- 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);
+ // ----------------------------------------------------------
+ // Form validation
+ // ----------------------------------------------------------
+
+ //puts error msg under specific field
+ function showFieldError(fieldId, message) {
+ var el = document.getElementById(fieldId);
+ if (el) el.textContent = message;
}
- 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");
- });
+ //clears error msg under specific field
+ function clearFieldError(fieldId) {
+ var el = document.getElementById(fieldId);
+ if (el) el.textContent = ""; //empty string = no error msg
}
- function showSuggestions(items) {
- visibleSuggestions = items;
- activeSuggestionIndex = -1;
- suggestions.textContent = "";
- if (!items.length) {
- hideSuggestions();
- return;
- }
- items.forEach(function (skill, index) {
- var item = document.createElement("div");
- item.className = "suggestion-item";
- item.id = "skills-suggestion-" + index;
- item.setAttribute("role", "option");
- item.setAttribute("aria-selected", "false");
- item.textContent = skill;
- item.addEventListener("mousedown", function (event) { event.preventDefault(); });
- item.addEventListener("mouseenter", function () {
- activeSuggestionIndex = index;
- renderSuggestionState();
- });
- item.addEventListener("click", function () {
- window.addSkill(skill);
- skillsInput.value = "";
- hideSuggestions();
- });
- suggestions.appendChild(item);
- });
- suggestions.style.display = "block";
- skillsInput.setAttribute("aria-expanded", "true");
+ //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;
- if (!selectedSkills.length) {
+
+ // 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;
}
@@ -567,19 +391,19 @@ updateProfileWidgets();
showFieldError("time-error", "Please select your time availability.");
valid = false;
}
+
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();
-
+ clearAllErrors()
+
if (skillsTextInput.value.trim()) {
addSkill(skillsTextInput.value);
skillsTextInput.value = "";
@@ -593,61 +417,56 @@ updateProfileWidgets();
// 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) //convert object to json string
+ body: JSON.stringify(payload)
})
- .then(function (res) {
- return res.json(); //parse the response as JSON
- })
+ .then(function (res) { return res.json(); })
.then(function (data) {
setLoadingState(false);
-
if (data.error) {
var generalErr = document.getElementById("form-error-general");
if (generalErr) generalErr.textContent = data.error;
return;
}
-
renderResults(data.projects || [], data.message);
})
.catch(function (err) {
- // 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);
+ console.error(err);
});
});
});
-
- // Manages the loading state of the form and results section(whats visible or not)
+ // Manages the loading state of the form and results section (what's 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 ? "true" : "false");
+ submitBtn.setAttribute("aria-busy", isLoading);
btnLabel.style.display = isLoading ? "none" : "inline";
btnLoading.style.display = isLoading ? "inline-flex" : "none";
+
if (isLoading) {
+ // Show the results section with only the loading indicator visible
resultsSection.style.display = "block";
resultsLoadingEl.style.display = "block";
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "none";
+ // Scroll down so the user can see the spinner without manually scrolling
resultsSection.scrollIntoView({ behavior: "smooth" });
} else {
- resultsLoadingEl.style.display = "none";
- resultsGrid.style.display = "grid"; //switch back to grid layout
+ resultsLoadingEl.style.display = "none";
+ resultsGrid.style.display = "grid"; //switch back to gird layout
}
}
@@ -664,61 +483,9 @@ updateProfileWidgets();
// 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 the "no results" message and hide the grid
- resultsGrid.style.display = "none";
+ 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";
-
- var interestEl = document.getElementById("interest");
- var selectedInterest = interestEl ? interestEl.value : null;
-
- // Show a friendly custom message when the user selected an interest
- if (emptyMessageEl) {
- 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.";
- }
- }
-
- 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 || "";
- 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;
-
- //takes the array of projects from the api and draws them on the page as cards
- //if array is empty it shows the "no results" message instead
- function renderResults(projects, message) {
- resultsSection.style.display = "block";
- resultsLoadingEl.style.display = "none";
- // Clear out any cards from a previous search before showing new ones
- resultsGrid.innerHTML = "";
-
- if (!projects || projects.length === 0) {
- resultsGrid.style.display = "none";
- resultsEmptyEl.style.display = "block";
if (message && emptyMessageEl) emptyMessageEl.textContent = message;
resultsSection.scrollIntoView({ behavior: "smooth" });
return;
@@ -727,523 +494,331 @@ updateProfileWidgets();
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" });
- main
}
+ // builds one project card as a DOM element and returns it
+ // the card has title, short description, tags and link
function buildProjectCard(project) {
var card = document.createElement("div");
card.className = "project-card";
+ // 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";
- 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);
- }
+ // 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";
- 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"));
+ // 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()));
+
+ // Time tag
+ tagsRow.appendChild(createTag("Time: " + project.time, "time"));
+ // Footer with view-details link
var footer = document.createElement("div");
footer.className = "project-card-footer";
- var saveButton = document.createElement("button");
- saveButton.type = "button";
- saveButton.className = "btn-save-project";
- saveButton.setAttribute("data-save-project-id", project.id);
- saveButton.setAttribute("aria-pressed", projectIsSaved(project.id) ? "true" : "false");
- if (projectIsSaved(project.id)) {
- saveButton.classList.add("saved");
- saveButton.textContent = "Saved";
- } else {
- saveButton.textContent = "Save Project";
- }
- saveButton.addEventListener("click", function () {
- toggleSavedProject(project, saveButton);
- });
-
var link = document.createElement("a");
link.className = "btn-details";
link.textContent = "View Full Project";
- link.href = "/project/" + project.id;
- footer.appendChild(saveButton);
+ link.href = "/project/" + project.id; //each project has a unique id
+
footer.appendChild(link);
+ // Assemble the card in order
card.appendChild(title);
card.appendChild(desc);
- card.appendChild(tags);
+ card.appendChild(tagsRow);
card.appendChild(footer);
- return card;
- }
-
- renderSavedProjects();
-
- function renderResults(projects, message) {
- resultsSection.style.display = "block";
- resultsLoadingEl.style.display = "none";
- resultsGrid.textContent = "";
- if (!projects || projects.length === 0) {
- resultsGrid.style.display = "none";
- resultsEmptyEl.style.display = "block";
- emptyMessageEl.textContent = message || "Try adjusting your skills or choosing a different interest area.";
- resultsSection.scrollIntoView({ behavior: "smooth" });
- return;
- }
- resultsEmptyEl.style.display = "none";
- resultsGrid.style.display = "grid";
- projects.forEach(function (project) { resultsGrid.appendChild(buildProjectCard(project)); });
- resultsSection.scrollIntoView({ behavior: "smooth" });
- }
-
- 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;
- });
- })
- .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.";
- });
- }
- function bindSearchForm(form, input) {
- if (!form || !input) return;
- form.addEventListener("submit", function (event) {
- event.preventDefault();
- runProjectSearch(input.value.trim());
- });
+ return card;
}
- bindSearchForm(document.getElementById("topic-search-form"), document.getElementById("topic-search"));
- bindSearchForm(document.getElementById("topic-search-form-mobile"), document.getElementById("topic-search-mobile"));
-
- skillsInput.setAttribute("role", "combobox");
- skillsInput.setAttribute("aria-expanded", "false");
- suggestions.setAttribute("role", "listbox");
-
- skillsInput.addEventListener("input", function () {
- showSuggestions(filteredSkills(skillsInput.value));
- });
- skillsInput.addEventListener("focus", function () {
- if (skillsInput.value.trim()) showSuggestions(filteredSkills(skillsInput.value));
- });
- skillsInput.addEventListener("blur", function () {
- window.setTimeout(hideSuggestions, 150);
- });
- skillsInput.addEventListener("keydown", function (event) {
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- if (!visibleSuggestions.length) showSuggestions(filteredSkills(skillsInput.value));
- if (!visibleSuggestions.length) return;
- event.preventDefault();
- activeSuggestionIndex = event.key === "ArrowDown"
- ? (activeSuggestionIndex + 1) % visibleSuggestions.length
- : (activeSuggestionIndex <= 0 ? visibleSuggestions.length - 1 : activeSuggestionIndex - 1);
- renderSuggestionState();
- return;
- }
- if (event.key === "Escape") {
- hideSuggestions();
- return;
- }
- if (event.key === "Enter") {
- event.preventDefault();
- if (activeSuggestionIndex >= 0 && visibleSuggestions[activeSuggestionIndex]) {
- window.addSkill(visibleSuggestions[activeSuggestionIndex]);
- } else {
- window.addSkill(skillsInput.value);
- }
- skillsInput.value = "";
- hideSuggestions();
- }
- });
-
- quickPickChips.forEach(function (chip) {
- chip.addEventListener("click", function () {
- var skill = chip.getAttribute("data-skill");
- if (isSelected(skill)) removeSkill(skill);
- else window.addSkill(skill);
- skillsInput.value = "";
- hideSuggestions();
- });
- });
-
- if (skillWrap) {
- skillWrap.addEventListener("click", function () { skillsInput.focus(); });
+ // helper to create a coloured tag element (used for skills, level, time tags on the cards)
+ function createTag(text, type) {
+ var span = document.createElement("span");
+ // The type becomes a BEM modifier so CSS can style each tag differently
+ span.className = "project-tag project-tag--" + type;
+ span.textContent = text;
+ return span;
}
- 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();
- });
+ 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 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.");
- });
- }
+} // end isIndexPage
- 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.";
- });
- });
- 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 = "";
- openModalBtn.focus(); // add this line
-}
+// ============================================================
+// DETAIL PAGE
+// ============================================================
+if (isDetailPage) {
- if (modal && openModalBtn && closeModalBtn && fetchBtn && githubInput && errorMsg) {
- openModalBtn.addEventListener("click", function () {
- modal.classList.add("active");
- githubInput.focus();
- });
- modal.addEventListener("keydown", function (event) {
- if (!modal.classList.contains("active")) return;
- var focusable = modal.querySelectorAll("button, input");
- var first = focusable[0];
- var last = focusable[focusable.length - 1];
- if (event.key === "Tab") {
- if (event.shiftKey && document.activeElement === first) {
- event.preventDefault();
- last.focus();
- } else if (!event.shiftKey && document.activeElement === last) {
- event.preventDefault();
- first.focus();
- }
- }
- if (event.key === "Escape") closeGithubModal();
-});
- closeModalBtn.addEventListener("click", closeGithubModal);
- modal.addEventListener("click", function (event) {
- if (event.target === modal) closeGithubModal();
- });
- fetchBtn.addEventListener("click", function () {
- 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) {
- errorMsg.textContent = err.message || "Failed to fetch skills.";
- })
- .finally(function () {
- fetchBtn.disabled = false;
- fetchBtn.textContent = "Fetch Skills";
- });
- });
- }
-})();
+ 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 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");
+ // Cache flag so code is only fetched once per page load
var codeFetched = false;
- 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);
- });
- }
-
- 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";
- recordCodeOpen();
+
+ // Only fetch the code on the first open, no need to re-fetch every time
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);
- document.addEventListener("keydown", function (event) {
- if (event.key === "Escape") 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
});
+ // ----------------------------------------------------------
+ // 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 = 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);
- }
- };
+ 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
if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(code).then(done);
+ navigator.clipboard.writeText(code).then(showCopySuccess).catch(function () {
+ fallbackCopy(code); // clipboard api failed, try the old way
+ });
} else {
- 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();
+ fallbackCopy(code); // Clipboard API not supported, use fallback method
}
});
}
- 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);
+ // 2. Close Github Input Modal
+ const closeGithubModal = () => {
+ modal.classList.remove('active');
+ githubInput.value = '';
+ errorMsg.textContent = '';
+ };
+
+ closeModalBtn.addEventListener('click', closeGithubModal);
+
+ // Close on clicking outside the card
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeGithubModal();
});
- updateRoadmapProgress();
- if (completionBtn) {
- completionBtn.addEventListener("click", function () {
- recordCompletion(PROJECT_ID, typeof PROJECT_TITLE !== "undefined" ? PROJECT_TITLE : "");
- showAchievementToast("Project completed", "Nice work finishing this project.");
- });
- }
-})();
+ // 3. Fetch Skills Logic
+ fetchBtn.addEventListener('click', async () => {
+ const username = githubInput.value.trim();
+ if (!username) return;
-(function initScrollButton() {
- var button = document.getElementById("scroll-top-btn");
- var icon = document.getElementById("scroll-btn-icon");
- if (!button) return;
- var atBottom = false;
+ 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';
+ }
+ });
+}
- function nearBottom() {
- return window.innerHeight + window.pageYOffset >= document.body.scrollHeight - 40;
- }
+/* ---- Scroll-to-top button ---- */
- 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 ? '' : '';
+/* Show the button only when the user has scrolled more than 300px */
+var SCROLL_THRESHOLD = 300;
+
+/* Get the button element; guard against pages that do not have it */
+var scrollTopBtn = document.getElementById('scroll-top-btn');
+
+/* Add or remove the .visible class based on scroll position */
+function handleScroll() {
+ if (!scrollTopBtn) return;
+ if (window.pageYOffset > SCROLL_THRESHOLD) {
+ scrollTopBtn.classList.add('visible');
+ } else {
+ scrollTopBtn.classList.remove('visible');
}
+}
- window.addEventListener("scroll", update, { passive: true });
- button.addEventListener("click", function () {
- window.scrollTo({ top: atBottom ? 0 : document.body.scrollHeight, behavior: "smooth" });
- });
- update();
-})();
+/* Smooth-scroll to the very top of the page */
+function scrollToTop() {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+}
+
+/* Only wire up listeners if the button exists on this page */
+if (scrollTopBtn) {
+ window.addEventListener('scroll', handleScroll);
+ scrollTopBtn.addEventListener('click', scrollToTop);
+}
diff --git a/templates/index.html b/templates/index.html
index fd3cb94..b691747 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -528,8 +528,12 @@ Find Your Next Project
-
-
+
+
+
+
+
@@ -683,13 +693,9 @@ Find Your Next Project
-
-
-
+ Finding matches...
+
+