diff --git a/data/projects.json b/data/projects.json index e98ca1a4..7376589f 100644 --- a/data/projects.json +++ b/data/projects.json @@ -362,7 +362,7 @@ "starter_code": "starter_code/ai_resume_analyzer.py" }, { - "id": 8, + "id": 11, "title": "Number Guessing Game", "skills": ["Python"], "level": "Beginner", @@ -393,7 +393,7 @@ "starter_code": "starter_code/number_guessing.py" }, { - "id": 9, + "id": 12, "title": "Simple Email Automation", "skills": ["Python"], "level": "Beginner", @@ -424,7 +424,7 @@ "starter_code": "starter_code/email_automation.py" }, { - "id": 10, + "id": 13, "title": "Quiz App", "skills": ["HTML", "CSS", "JavaScript"], "level": "Beginner", diff --git a/routes/main_routes.py b/routes/main_routes.py index 07fb6b18..203fc8a0 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -101,10 +101,37 @@ def recommend(): @main.route("/project/") def project_detail(project_id): """Render the full detail page for a single project.""" + project = find_project_by_id(project_id) + if not project: abort(404) - return render_template("project.html", project=project) + + projects = load_all_projects() + + current_index = next( + (index for index, p in enumerate(projects) if p["id"] == project_id), + None + ) + + prev_project = ( + projects[current_index - 1] + if current_index is not None and current_index > 0 + else None + ) + + next_project = ( + projects[current_index + 1] + if current_index is not None and current_index < len(projects) - 1 + else None + ) + + return render_template( + "project.html", + project=project, + prev_project=prev_project, + next_project=next_project + ) @main.route("/project//code") diff --git a/static/script.js b/static/script.js index 73667071..c03f7b10 100644 --- a/static/script.js +++ b/static/script.js @@ -692,53 +692,69 @@ if (isIndexPage) { hideSuggestions(); } - if (!validateForm()) return; //stop - anything missing/invalid + clearAllErrors(); - setLoadingState(true); + if (skillsTextInput.value.trim()) { + addSkill(skillsTextInput.value); + skillsTextInput.value = ""; + hideSuggestions(); + } - // Allow browser to paint spinner before request starts - requestAnimationFrame(function () { + if (!validateForm()) return; - var payload = { - skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), - level: document.getElementById("level").value, - interest: document.getElementById("interest").value, - time: document.getElementById("time").value - }; - - fetch("/api/recommend", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }) - .then(function (res) { - return res.json(); - }) - .then(function (data) { + setLoadingState(true); - setLoadingState(false); + requestAnimationFrame(function () { - if (data.error) { - var generalErr = document.getElementById("form-error-general"); + var payload = { + skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), + level: document.getElementById("level").value, + interest: document.getElementById("interest").value, + time: document.getElementById("time").value + }; - if (generalErr) { - generalErr.textContent = data.error; - } + fetch("/api/recommend", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }) + .then(function (res) { + return res.json(); + }) + .then(function (data) { - return; - } + setLoadingState(false); - renderResults(data.projects || [], data.message); - }) - .catch(function () { - setLoadingState(false); + if (data.error) { var generalErr = document.getElementById("form-error-general"); + if (generalErr) { - generalErr.textContent = "An error occurred. Please try again."; + generalErr.textContent = data.error; } - }); - }); + + return; + } + + renderResults(data.projects || [], data.message); + }) + .catch(function (err) { + + setLoadingState(false); + + var generalErr = document.getElementById("form-error-general"); + + if (generalErr) { + generalErr.textContent = + "Something went wrong. Please try again."; + } + + console.error("API request failed:", err); + }); + }); +}); // Manages the loading state of the form and results section(whats visible or not) function setLoadingState(isLoading) { @@ -769,176 +785,120 @@ if (isIndexPage) { //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 the "no results" message and hide the grid - 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."; - } +function renderResults(projects, message) { + resultsSection.style.display = "block"; + resultsLoadingEl.style.display = "none"; + + // Clear previous cards + resultsGrid.innerHTML = ""; + + // No results + if (!projects || projects.length === 0) { + resultsGrid.style.display = "none"; + resultsEmptyEl.style.display = "block"; + + // Friendly custom message + 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 interest 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; - } + resultsSection.scrollIntoView({ + behavior: "smooth" + }); - resultsEmptyEl.style.display = "none"; - resultsGrid.style.display = "grid"; + return; + } - //build a card for each project and add it to the grid - projects.forEach(function (project) { - resultsGrid.appendChild(buildProjectCard(project)); - }); + // Show results grid + resultsEmptyEl.style.display = "none"; + resultsGrid.style.display = "grid"; - resultsSection.scrollIntoView({ behavior: "smooth" }); - } + // Render project cards + projects.forEach(function (project) { + resultsGrid.appendChild(buildProjectCard(project)); + }); - // 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"; - // 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")); - }); + resultsSection.scrollIntoView({ + behavior: "smooth" + }); +} - // Level tag (colour-coded via CSS class) - // Lowercase so it matches the CSS class names like "level beginner", "level advanced" - var levelClass = "level " + (project.level || "").toLowerCase(); - tagsRow.appendChild(createTag(project.level, levelClass)); - - // Time tag - tagsRow.appendChild(createTag("Time: " + project.time, "time")); - - // 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 wrapper — keeps text and button as separate child elements - // so we never use textContent (which would wipe out child nodes like the button) - var desc = document.createElement("p"); - desc.className = "project-card-desc"; - - // Separate span for the description text so we can update it - // without touching the toggle button - var descText = document.createElement("span"); - descText.className = "project-card-desc-text"; - - var shortText = truncate(project.description, 120); - var fullText = project.description; - var isExpanded = false; - - descText.textContent = shortText; - desc.appendChild(descText); - - // Only add Read More button if description is actually truncated - if (fullText.length > 120) { - var readMoreBtn = document.createElement("button"); - readMoreBtn.className = "read-more-btn"; - readMoreBtn.textContent = "Read more"; - // aria-expanded tells screen readers whether the content is expanded or not - readMoreBtn.setAttribute("aria-expanded", "false"); - - readMoreBtn.addEventListener("click", function () { - isExpanded = !isExpanded; - // Update only the text span — button stays in the DOM untouched - descText.textContent = isExpanded ? fullText : shortText; - readMoreBtn.textContent = isExpanded ? "Read less" : "Read more"; - readMoreBtn.setAttribute("aria-expanded", isExpanded ? "true" : "false"); - }); + +function buildProjectCard(project) { - desc.appendChild(readMoreBtn); - } + var card = document.createElement("div"); + card.className = "project-card"; - // Tags row - var tagsRow = document.createElement("div"); - tagsRow.className = "project-card-tags"; + // Title + var title = document.createElement("h3"); + title.className = "project-card-title"; + title.textContent = project.title; - (project.skills || []).forEach(function (skill) { - tagsRow.appendChild(createTag(skill, "skill")); - }); + // Description + var desc = document.createElement("p"); + desc.className = "project-card-desc"; + desc.textContent = truncate(project.description, 120); - var levelClass = "level " + (project.level || "").toLowerCase(); - tagsRow.appendChild(createTag(project.level, levelClass)); - tagsRow.appendChild(createTag("Time: " + project.time, "time")); - - // Assemble the card in order - card.appendChild(title); - card.appendChild(desc); - card.appendChild(tagsRow); - card.appendChild(footer); - - var link = document.createElement("a"); - link.className = "btn-details"; - link.textContent = "View Full Project"; - link.href = "/project/" + project.id; - - // 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; - } + // Tags row + var tagsRow = document.createElement("div"); + tagsRow.className = "project-card-tags"; - 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; - } + // Show all project skills + (project.skills || []).forEach(function (skill) { + tagsRow.appendChild(createTag(skill, "skill")); + }); - } // end isIndexPage + // Level tag + var levelClass = "level " + (project.level || "").toLowerCase(); + tagsRow.appendChild(createTag(project.level, levelClass)); + // Time tag + tagsRow.appendChild(createTag("Time: " + project.time, "time")); - // ============================================================ - // DETAIL PAGE - // ============================================================ - if (isDetailPage) { + // Footer + var footer = document.createElement("div"); + footer.className = "project-card-footer"; - 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
+  // Link button
+  var link = document.createElement("a");
+  link.className = "btn-details";
+  link.textContent = "View Full Project";
+  link.href = "/project/" + project.id;
+
+  footer.appendChild(link);
+
+  // Assemble card
+  card.appendChild(title);
+  card.appendChild(desc);
+  card.appendChild(tagsRow);
+  card.appendChild(footer);
+
+  return card;
+}
+function createTag(text, type) {
+  var span = document.createElement("span");
+  span.className = "project-tag project-tag--" + type;
+  span.textContent = text;
+  return span;
+}
+
+function truncate(text, maxLength) {
+  if (!text) return "";
+
+  return text.length > maxLength
+    ? text.slice(0, maxLength) + "..."
+    : text;
+}
+// end isIndexPage
 
     // Cache flag so code is only fetched once per page load
     var codeFetched = false;
@@ -1190,4 +1150,5 @@ function scrollToTop() {
 if (scrollTopBtn) {
     window.addEventListener('scroll', handleScroll);
     scrollTopBtn.addEventListener('click', scrollToTop);
-  }
+}
+}
diff --git a/static/style.css b/static/style.css
index 3050d2e5..50f4dd56 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3394,6 +3394,88 @@ html[data-theme="dark"] .btn-view-code-sm {
   color: #a5b4fc;
 }
 
+.line-content {
+  flex: 1;
+  white-space: pre;
+  color: #e6edf3;
+}
+
+/* ============================================================
+   Project Navigation
+   ============================================================ */
+
+.project-navigation {
+  margin-top: 4rem;
+  padding: 2rem 0;
+}
+
+.project-navigation-inner {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  flex-wrap: wrap;
+}
+
+.project-nav-card {
+  flex: 1 1 240px;
+  min-width: 240px;
+  padding: 1.25rem;
+  border-radius: 16px;
+
+  background: #111827;
+  border: 1px solid #1f2937;
+
+  text-decoration: none;
+
+  transition:
+    transform 0.2s ease,
+    border-color 0.2s ease,
+    background-color 0.2s ease;
+}
+
+.project-nav-card:hover {
+  transform: translateY(-2px);
+  border-color: #374151;
+  background: #0f172a;
+}
+
+.project-nav-card:focus-visible {
+  outline: 2px solid #60a5fa;
+  outline-offset: 3px;
+}
+
+.project-nav-card--right {
+  text-align: right;
+}
+
+.project-nav-label {
+  display: block;
+  margin-bottom: 0.4rem;
+
+  font-size: 0.85rem;
+  color: #9ca3af;
+}
+
+.project-nav-title {
+  display: block;
+
+  font-size: 1rem;
+  font-weight: 600;
+  line-height: 1.4;
+
+  color: #f9fafb;
+}
+
+/* Mobile stacking */
+@media (max-width: 640px) {
+  .project-navigation-inner {
+    flex-direction: column;
+  }
+
+  .project-nav-card--right {
+    text-align: left;
+  }
+}
 [data-theme="dark"] .navbar {
   background: rgba(5, 8, 30, 0.92);
 }
diff --git a/templates/project.html b/templates/project.html
index c9d49b8e..c0b191a2 100644
--- a/templates/project.html
+++ b/templates/project.html
@@ -352,6 +352,31 @@