From 9119a3e077a533d52460e90bc2e6f264dc8d1088 Mon Sep 17 00:00:00 2001 From: Nancy <9d.24.nancy.sangani@gmail.com> Date: Mon, 8 Jun 2026 15:25:42 +0530 Subject: [PATCH] feat: add quick-save bookmarks and session restore via localStorage --- static/bookmarks.css | 53 +++++++ static/bookmarks.js | 304 ++++++++++++++++++++++++++++++++++++++++ templates/index.html | 2 + tests/test_bookmarks.py | 281 +++++++++++++++++++++++++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 static/bookmarks.css create mode 100644 static/bookmarks.js create mode 100644 tests/test_bookmarks.py diff --git a/static/bookmarks.css b/static/bookmarks.css new file mode 100644 index 0000000..1147a4d --- /dev/null +++ b/static/bookmarks.css @@ -0,0 +1,53 @@ +/* ============================================================= + bookmarks.css — DevPath Quick-Save additions + Appended to / imported alongside style.css. + Only adds styles not already present in style.css. + ============================================================= */ + +/* ------------------------------------------------------------------ + Bookmark / Save button — icon + label alignment + The base .btn-save-project class lives in style.css. + These rules add the SVG icon layout and the active-fill state. + ------------------------------------------------------------------ */ +.btn-save-project { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-save-project svg { + flex-shrink: 0; + transition: fill var(--t), stroke var(--t); +} + +/* Filled bookmark when saved */ +.btn-save-project.saved svg { + fill: currentColor; +} + +/* ------------------------------------------------------------------ + Saved Projects Panel — hide by default (shown via JS when non-empty) + ------------------------------------------------------------------ */ +.saved-projects-panel { + display: none; +} + +/* ------------------------------------------------------------------ + Error page correlation ID (used by 400/429/500 templates) + ------------------------------------------------------------------ */ +.error-reference { + font-size: 0.78rem; + color: var(--text-light); + margin-top: -16px; + margin-bottom: 24px; +} + +.error-reference code { + font-family: var(--font-mono); + background: var(--gray-100); + padding: 2px 6px; + border-radius: var(--r-xs); + font-size: 0.75rem; + color: var(--gray-600); +} diff --git a/static/bookmarks.js b/static/bookmarks.js new file mode 100644 index 0000000..9d79322 --- /dev/null +++ b/static/bookmarks.js @@ -0,0 +1,304 @@ +/** + * static/bookmarks.js + * Quick-Save / Session-Restore utility for DevPath. + * + * Responsibilities + * ---------------- + * 1. SAVED PROJECTS – persist bookmarked project cards across browser sessions + * using the existing `devpathSavedProjects` key so the data stays compatible + * with what script.js already stores. + * + * 2. SESSION RESTORE – persist the last-used form values (skills, level, + * interest, time) so users never have to re-enter them after a refresh or + * accidental tab close. + * + * This file exposes a single global `DevPathBookmarks` object. + * script.js does not need to be modified — it already calls getSavedProjects / + * saveSavedProjects / renderSavedProjects which rely on the same storage key. + * + * Public API + * ---------- + * DevPathBookmarks.restoreSession() – called on DOMContentLoaded + * DevPathBookmarks.saveSession() – called on form change / submit + * DevPathBookmarks.clearSession() – called by the Clear Filters button + * DevPathBookmarks.getSaved() – returns array of saved project objects + * DevPathBookmarks.isSaved(id) – boolean + * DevPathBookmarks.save(project) – add a project + * DevPathBookmarks.remove(id) – remove a project + * DevPathBookmarks.renderPanel() – redraw the saved-projects panel + */ + +var DevPathBookmarks = (function () { + "use strict"; + + /* ------------------------------------------------------------------ */ + /* Constants */ + /* ------------------------------------------------------------------ */ + var SAVED_KEY = "devpathSavedProjects"; // shared with script.js + var SESSION_KEY = "devpathSessionForm"; + + /* ------------------------------------------------------------------ */ + /* Safe localStorage helpers */ + /* ------------------------------------------------------------------ */ + function lsGet(key) { + try { + return JSON.parse(localStorage.getItem(key) || "null"); + } catch (err) { + return null; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (err) { + console.warn("[DevPathBookmarks] localStorage write failed:", err); + return false; + } + } + + /* ------------------------------------------------------------------ */ + /* Saved Projects */ + /* ------------------------------------------------------------------ */ + function getSaved() { + var data = lsGet(SAVED_KEY); + return Array.isArray(data) ? data : []; + } + + function isSaved(projectId) { + return getSaved().some(function (p) { + return String(p.id) === String(projectId); + }); + } + + function save(project) { + if (!project || !project.id) return false; + var saved = getSaved(); + if (saved.some(function (p) { return String(p.id) === String(project.id); })) { + return false; // already saved + } + saved.unshift({ + id: project.id, + title: project.title || "Project " + project.id, + level: project.level || "", + time: project.time || "", + skills: Array.isArray(project.skills) ? project.skills.slice(0, 4) : [] + }); + lsSet(SAVED_KEY, saved); + renderPanel(); + return true; + } + + function remove(projectId) { + var saved = getSaved().filter(function (p) { + return String(p.id) !== String(projectId); + }); + lsSet(SAVED_KEY, saved); + renderPanel(); + // Sync any visible save buttons for this project + document.querySelectorAll("[data-save-project-id='" + projectId + "']").forEach(function (btn) { + btn.classList.remove("saved"); + btn.setAttribute("aria-pressed", "false"); + btn.setAttribute("aria-label", "Save project"); + _setButtonContent(btn, false); + }); + } + + function toggle(project, button) { + if (isSaved(project.id)) { + remove(project.id); + } else { + save(project); + if (button) { + button.classList.add("saved"); + button.setAttribute("aria-pressed", "true"); + button.setAttribute("aria-label", "Remove saved project"); + _setButtonContent(button, true); + } + } + } + + /* Build the bookmark icon + label content for the save button */ + function _setButtonContent(button, saved) { + button.innerHTML = + '" + + (saved ? " Saved" : " Save Project"); + } + + /* ------------------------------------------------------------------ */ + /* Saved Projects Panel */ + /* ------------------------------------------------------------------ */ + function renderPanel() { + var panel = document.getElementById("saved-projects-panel"); + var list = document.getElementById("saved-projects-list"); + var count = document.getElementById("saved-projects-count"); + if (!list || !count || !panel) return; + + var saved = getSaved(); + count.textContent = saved.length + (saved.length === 1 ? " saved" : " saved"); + + // Show panel only when there is at least one saved project + panel.style.display = saved.length ? "block" : "none"; + + list.textContent = ""; + + if (!saved.length) { + var empty = document.createElement("p"); + empty.className = "saved-projects-empty"; + empty.textContent = "No saved projects yet. Click \u201CSave Project\u201D on any result card."; + list.appendChild(empty); + return; + } + + saved.forEach(function (project) { + var item = document.createElement("article"); + item.className = "saved-project-item"; + + var titleLink = document.createElement("a"); + titleLink.href = "/project/" + project.id; + titleLink.textContent = project.title; + + var meta = document.createElement("span"); + meta.textContent = [project.level, project.time].filter(Boolean).join(" \u00B7 "); + + var removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "saved-project-remove"; + removeBtn.setAttribute("aria-label", "Remove " + project.title + " from saved projects"); + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", function () { + remove(project.id); + }); + + item.appendChild(titleLink); + item.appendChild(meta); + item.appendChild(removeBtn); + list.appendChild(item); + }); + } + + /* ------------------------------------------------------------------ */ + /* Session Persistence */ + /* ------------------------------------------------------------------ */ + + /** + * Save current form values to localStorage. + * Skills are read from the hidden input (comma-separated) which script.js + * keeps in sync via syncSkillsHiddenInput(). + */ + function saveSession() { + var skillsHidden = document.getElementById("skills"); + var level = document.getElementById("level"); + var interest = document.getElementById("interest"); + var time = document.getElementById("time"); + + if (!skillsHidden && !level && !interest && !time) return; + + lsSet(SESSION_KEY, { + skills: skillsHidden ? skillsHidden.value : "", + level: level ? level.value : "", + interest: interest ? interest.value : "", + time: time ? time.value : "" + }); + } + + /** + * Restore previously saved form values on page load. + * Skills are re-added via the global window.addSkill() exposed by script.js. + */ + function restoreSession() { + var session = lsGet(SESSION_KEY); + if (!session || typeof session !== "object") return; + + // Restore skills: split comma-separated string, add each via script.js helper + var rawSkills = (session.skills || "").trim(); + if (rawSkills) { + // window.addSkill is defined by script.js; it deduplicates and updates UI + var skillList = rawSkills.split(",").map(function (s) { return s.trim(); }).filter(Boolean); + skillList.forEach(function (skill) { + if (typeof window.addSkill === "function") window.addSkill(skill); + }); + } + + // Restore select values + _setSelectValue("level", session.level); + _setSelectValue("interest", session.interest); + _setSelectValue("time", session.time); + } + + function _setSelectValue(id, value) { + if (!value) return; + var el = document.getElementById(id); + if (!el) return; + // Only restore if the option actually exists in the DOM + var options = Array.prototype.slice.call(el.options); + var match = options.some(function (opt) { return opt.value === value; }); + if (match) el.value = value; + } + + /** Remove the persisted session (called by Clear Filters). */ + function clearSession() { + try { + localStorage.removeItem(SESSION_KEY); + } catch (err) {} + } + + /* ------------------------------------------------------------------ */ + /* Auto-wire on DOMContentLoaded */ + /* ------------------------------------------------------------------ */ + document.addEventListener("DOMContentLoaded", function () { + // Restore session after script.js has run (it runs synchronously before + // this callback fires, so window.addSkill is already defined). + restoreSession(); + renderPanel(); + + // Save session whenever the user changes a form field + ["level", "interest", "time"].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener("change", saveSession); + }); + + // Save session when skills hidden input changes (dispatched by script.js) + var skillsHidden = document.getElementById("skills"); + if (skillsHidden) { + skillsHidden.addEventListener("change", saveSession); + } + + // Also save on form submit so the values are definitely persisted + var form = document.getElementById("recommend-form"); + if (form) { + form.addEventListener("submit", function () { + saveSession(); + }, true); // capture phase so it runs before script.js submit handler + } + + // Clear session when the Clear Filters button is clicked + var clearBtn = document.getElementById("clear-filters-btn"); + if (clearBtn) { + clearBtn.addEventListener("click", function () { + clearSession(); + renderPanel(); + }); + } + }); + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + return { + getSaved: getSaved, + isSaved: isSaved, + save: save, + remove: remove, + toggle: toggle, + renderPanel: renderPanel, + saveSession: saveSession, + restoreSession: restoreSession, + clearSession: clearSession, + setButtonContent: _setButtonContent + }; +}()); diff --git a/templates/index.html b/templates/index.html index fd3cb94..1015db6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,6 +28,7 @@ {% include 'partials/theme_head.html' %} + @@ -921,6 +922,7 @@