From 6a3753268de7f8ddf72547e366f631084c48d222 Mon Sep 17 00:00:00 2001 From: Jidnyasa-P Date: Thu, 23 Jul 2026 23:54:06 +0530 Subject: [PATCH] feat: add persistent course bookmarks and wishlist --- .../controllers/courseBookmarkController.js | 387 +++++++++++++++ backend/index.js | 7 +- backend/routers/courseBookmarkRoutes.js | 23 + backend/schemas/courseBookmarkModel.js | 30 ++ docs/course-bookmarks.md | 142 ++++++ frontend/src/App.jsx | 11 + .../components/bookmarks/BookmarkButton.jsx | 89 ++++ .../src/components/bookmarks/Bookmarks.css | 443 ++++++++++++++++++ .../src/components/bookmarks/SavedCourses.jsx | 414 ++++++++++++++++ .../bookmarks/SavedCoursesNavLink.jsx | 21 + frontend/src/components/common/AllCourses.jsx | 5 + frontend/src/components/common/NavBar.jsx | 2 + .../components/user/student/CourseContent.jsx | 3 +- frontend/src/context/BookmarksContext.jsx | 221 +++++++++ 14 files changed, 1794 insertions(+), 4 deletions(-) create mode 100644 backend/controllers/courseBookmarkController.js create mode 100644 backend/routers/courseBookmarkRoutes.js create mode 100644 backend/schemas/courseBookmarkModel.js create mode 100644 docs/course-bookmarks.md create mode 100644 frontend/src/components/bookmarks/BookmarkButton.jsx create mode 100644 frontend/src/components/bookmarks/Bookmarks.css create mode 100644 frontend/src/components/bookmarks/SavedCourses.jsx create mode 100644 frontend/src/components/bookmarks/SavedCoursesNavLink.jsx create mode 100644 frontend/src/context/BookmarksContext.jsx diff --git a/backend/controllers/courseBookmarkController.js b/backend/controllers/courseBookmarkController.js new file mode 100644 index 0000000..7522f85 --- /dev/null +++ b/backend/controllers/courseBookmarkController.js @@ -0,0 +1,387 @@ +const mongoose = require("mongoose"); +const CourseBookmark = require("../schemas/courseBookmarkModel"); +const Course = require("../schemas/courseModel"); + +const ALLOWED_SORTS = new Set([ + "recent", + "title-asc", + "title-desc", + "price-asc", + "price-desc", +]); + +const parsePositiveInteger = (value, fallback, maximum) => { + const parsed = Number.parseInt(value, 10); + + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback; + } + + return Math.min(parsed, maximum); +}; + +const getUserId = (req) => + req.user?._id?.toString() || req.body?.userId || null; + +const isPaidCourse = (price) => /\d/.test(String(price || "")); + +const parsePrice = (price) => { + const parsed = Number.parseFloat( + String(price || "").replace(/[^0-9.-]/g, ""), + ); + + return Number.isFinite(parsed) ? parsed : 0; +}; + +const serializeCourse = (course) => { + if (!course) { + return { + id: null, + title: "Course unavailable", + category: "Unavailable", + educator: "Unknown", + description: + "This saved course is no longer available in the catalog.", + price: null, + numericPrice: 0, + accessType: "unavailable", + availability: "deleted", + enrolled: 0, + }; + } + + const paid = isPaidCourse(course.C_price); + + return { + id: course._id.toString(), + title: course.C_title, + category: course.C_categories, + educator: course.C_educator, + description: course.C_description, + price: course.C_price || "Free", + numericPrice: paid ? parsePrice(course.C_price) : 0, + accessType: paid ? "paid" : "free", + availability: "available", + enrolled: course.enrolled || 0, + createdAt: course.createdAt, + updatedAt: course.updatedAt, + }; +}; + +const addBookmark = async (req, res) => { + try { + const userId = getUserId(req); + const { courseId } = req.params; + + if (!userId || !mongoose.Types.ObjectId.isValid(courseId)) { + return res.status(400).send({ + success: false, + message: "A valid course and authenticated user are required.", + }); + } + + const course = await Course.findById(courseId) + .select("_id C_title") + .lean(); + + if (!course) { + return res.status(404).send({ + success: false, + message: "Course not found.", + }); + } + + const result = await CourseBookmark.findOneAndUpdate( + { userId, courseId }, + { $setOnInsert: { userId, courseId } }, + { upsert: true, new: true, rawResult: true }, + ); + + const created = Boolean(result?.lastErrorObject?.upserted); + + return res.status(created ? 201 : 200).send({ + success: true, + created, + bookmarked: true, + courseId, + message: created + ? "Course saved successfully." + : "Course was already saved.", + }); + } catch (error) { + if (error?.code === 11000) { + return res.status(200).send({ + success: true, + created: false, + bookmarked: true, + courseId: req.params.courseId, + message: "Course was already saved.", + }); + } + + console.error("Unable to save course bookmark:", error); + + return res.status(500).send({ + success: false, + message: "Unable to save this course.", + }); + } +}; + +const removeBookmark = async (req, res) => { + try { + const userId = getUserId(req); + const { courseId } = req.params; + + if (!mongoose.Types.ObjectId.isValid(courseId)) { + return res.status(400).send({ + success: false, + message: "Invalid course ID.", + }); + } + + const result = await CourseBookmark.deleteOne({ userId, courseId }); + + return res.status(200).send({ + success: true, + bookmarked: false, + removed: result.deletedCount > 0, + courseId, + message: + result.deletedCount > 0 + ? "Course removed from saved courses." + : "Course was not in your saved courses.", + }); + } catch (error) { + console.error("Unable to remove course bookmark:", error); + + return res.status(500).send({ + success: false, + message: "Unable to remove this saved course.", + }); + } +}; + +const getBookmarkStatus = async (req, res) => { + try { + const userId = getUserId(req); + const rawIds = [ + ...String(req.query.courseIds || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean), + ...(Array.isArray(req.query.courseId) + ? req.query.courseId + : req.query.courseId + ? [req.query.courseId] + : []), + ]; + + const courseIds = [...new Set(rawIds)].filter((id) => + mongoose.Types.ObjectId.isValid(id), + ); + + if (courseIds.length > 100) { + return res.status(400).send({ + success: false, + message: "A maximum of 100 course IDs can be checked at once.", + }); + } + + const bookmarks = await CourseBookmark.find({ + userId, + courseId: { $in: courseIds }, + }) + .select("courseId") + .lean(); + + return res.status(200).send({ + success: true, + data: bookmarks.map((bookmark) => + bookmark.courseId.toString(), + ), + count: bookmarks.length, + }); + } catch (error) { + console.error("Unable to retrieve bookmark status:", error); + + return res.status(500).send({ + success: false, + message: "Unable to retrieve bookmark status.", + }); + } +}; + +const getSavedCourses = async (req, res) => { + try { + const userId = getUserId(req); + const page = parsePositiveInteger(req.query.page, 1, 100000); + const limit = parsePositiveInteger(req.query.limit, 12, 50); + const category = String(req.query.category || "").trim(); + const access = String(req.query.access || "").trim().toLowerCase(); + const availability = String( + req.query.availability || "", + ).trim().toLowerCase(); + const search = String(req.query.search || "") + .trim() + .toLowerCase() + .slice(0, 120); + const sort = String(req.query.sort || "recent").trim().toLowerCase(); + + if (access && !["free", "paid"].includes(access)) { + return res.status(400).send({ + success: false, + message: "Invalid access filter.", + }); + } + + if ( + availability && + !["available", "deleted"].includes(availability) + ) { + return res.status(400).send({ + success: false, + message: "Invalid availability filter.", + }); + } + + if (!ALLOWED_SORTS.has(sort)) { + return res.status(400).send({ + success: false, + message: "Invalid saved-course sort option.", + }); + } + + const bookmarkDocs = await CourseBookmark.find({ userId }) + .populate({ + path: "courseId", + select: + "C_title C_categories C_educator C_description C_price enrolled createdAt updatedAt", + }) + .sort({ createdAt: -1 }) + .lean(); + + let items = bookmarkDocs.map((bookmark) => ({ + bookmarkId: bookmark._id.toString(), + savedAt: bookmark.createdAt, + course: serializeCourse(bookmark.courseId), + })); + + if (search) { + items = items.filter(({ course }) => + [ + course.title, + course.category, + course.educator, + course.description, + ].some((field) => + String(field || "").toLowerCase().includes(search), + ), + ); + } + + if (category) { + items = items.filter( + ({ course }) => + String(course.category || "").toLowerCase() === + category.toLowerCase(), + ); + } + + if (access) { + items = items.filter( + ({ course }) => course.accessType === access, + ); + } + + if (availability) { + items = items.filter( + ({ course }) => course.availability === availability, + ); + } + + const sorters = { + recent: (a, b) => + new Date(b.savedAt || 0) - new Date(a.savedAt || 0), + "title-asc": (a, b) => + a.course.title.localeCompare(b.course.title), + "title-desc": (a, b) => + b.course.title.localeCompare(a.course.title), + "price-asc": (a, b) => + a.course.numericPrice - b.course.numericPrice, + "price-desc": (a, b) => + b.course.numericPrice - a.course.numericPrice, + }; + + items.sort(sorters[sort]); + + const categories = [ + ...new Set( + bookmarkDocs + .map((bookmark) => bookmark.courseId?.C_categories) + .filter(Boolean), + ), + ].sort((a, b) => a.localeCompare(b)); + + const totalItems = items.length; + const totalPages = Math.max(1, Math.ceil(totalItems / limit)); + const safePage = Math.min(page, totalPages); + const start = (safePage - 1) * limit; + + return res.status(200).send({ + success: true, + data: items.slice(start, start + limit), + categories, + pagination: { + page: safePage, + limit, + totalItems, + totalPages, + hasPreviousPage: safePage > 1, + hasNextPage: safePage < totalPages, + }, + filters: { + search, + category, + access, + availability, + sort, + }, + }); + } catch (error) { + console.error("Unable to retrieve saved courses:", error); + + return res.status(500).send({ + success: false, + message: "Unable to retrieve saved courses.", + }); + } +}; + +const clearBookmarks = async (req, res) => { + try { + const userId = getUserId(req); + const result = await CourseBookmark.deleteMany({ userId }); + + return res.status(200).send({ + success: true, + removedCount: result.deletedCount, + message: "Saved courses cleared successfully.", + }); + } catch (error) { + console.error("Unable to clear saved courses:", error); + + return res.status(500).send({ + success: false, + message: "Unable to clear saved courses.", + }); + } +}; + +module.exports = { + addBookmark, + removeBookmark, + getBookmarkStatus, + getSavedCourses, + clearBookmarks, +}; diff --git a/backend/index.js b/backend/index.js index 703d4f7..53ea184 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,4 +1,4 @@ -// Serve admin dashboard at /api/admin/dashboard +// Serve admin dashboard at /api/admin/dashboard // (Moved below app initialization) const express = require('express') @@ -23,7 +23,7 @@ app.use(cors()) const uploadsDir = path.join(__dirname, "uploads"); -// Create uploads folder if it doesn’t exist +// Create uploads folder if it doesn’t exist if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir); } @@ -49,7 +49,8 @@ app.get('/api/admin', (req, res) => { ///ROUTES/// app.use('/api/admin', require('./routers/adminRoutes')) app.use('/api/user', require('./routers/userRoutes')) +app.use('/api/bookmarks', require('./routers/courseBookmarkRoutes')) -app.listen(PORT, () => console.log(`running on ${PORT}`)) \ No newline at end of file +app.listen(PORT, () => console.log(`running on ${PORT}`)) diff --git a/backend/routers/courseBookmarkRoutes.js b/backend/routers/courseBookmarkRoutes.js new file mode 100644 index 0000000..2b19dce --- /dev/null +++ b/backend/routers/courseBookmarkRoutes.js @@ -0,0 +1,23 @@ +const express = require("express"); +const authMiddleware = require("../middlewares/authMiddleware"); +const checkRole = require("../middlewares/roleMiddleware"); +const { + addBookmark, + removeBookmark, + getBookmarkStatus, + getSavedCourses, + clearBookmarks, +} = require("../controllers/courseBookmarkController"); + +const router = express.Router(); + +router.use(authMiddleware); +router.use(checkRole(["student", "Student"])); + +router.get("/", getSavedCourses); +router.get("/status", getBookmarkStatus); +router.post("/:courseId", addBookmark); +router.delete("/:courseId", removeBookmark); +router.delete("/", clearBookmarks); + +module.exports = router; diff --git a/backend/schemas/courseBookmarkModel.js b/backend/schemas/courseBookmarkModel.js new file mode 100644 index 0000000..1a24403 --- /dev/null +++ b/backend/schemas/courseBookmarkModel.js @@ -0,0 +1,30 @@ +const mongoose = require("mongoose"); + +const courseBookmarkSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "user", + required: true, + index: true, + }, + courseId: { + type: mongoose.Schema.Types.ObjectId, + ref: "course", + required: true, + index: true, + }, + }, + { + timestamps: true, + versionKey: false, + }, +); + +courseBookmarkSchema.index( + { userId: 1, courseId: 1 }, + { unique: true }, +); +courseBookmarkSchema.index({ userId: 1, createdAt: -1 }); + +module.exports = mongoose.model("courseBookmark", courseBookmarkSchema); diff --git a/docs/course-bookmarks.md b/docs/course-bookmarks.md new file mode 100644 index 0000000..7a94200 --- /dev/null +++ b/docs/course-bookmarks.md @@ -0,0 +1,142 @@ +# Course bookmarks and student wishlist + +## Backend API + +All endpoints require an authenticated student token. + +```http +GET /api/bookmarks +GET /api/bookmarks/status?courseIds=id1,id2 +POST /api/bookmarks/:courseId +DELETE /api/bookmarks/:courseId +DELETE /api/bookmarks +``` + +Supported list parameters: + +| Parameter | Values | +|---|---| +| `page` | Positive integer | +| `limit` | 1–50 | +| `search` | Course, category, educator, description | +| `category` | Exact category | +| `access` | `free`, `paid` | +| `availability` | `available`, `deleted` | +| `sort` | `recent`, `title-asc`, `title-desc`, `price-asc`, `price-desc` | + +## Database rules + +A dedicated bookmark model stores only: + +- User ID +- Course ID +- Created and updated timestamps + +A compound unique index on `{ userId, courseId }` prevents duplicates. + +When a course is deleted, Mongoose population returns `null`. The API retains the +bookmark and returns an unavailable placeholder so the student can see and remove +the stale saved item safely. + +## Frontend integration + +### 1. Wrap the application + +In `frontend/src/App.jsx`: + +```jsx +import { BookmarksProvider } from "./context/BookmarksContext"; +``` + +Wrap the existing router or route tree: + +```jsx + + + {/* existing routes */} + + +``` + +Do not add a second `Router` if the application already has one. + +### 2. Add the Saved Courses route + +```jsx +import SavedCourses from "./components/bookmarks/SavedCourses"; +``` + +Inside ``: + +```jsx +} /> +``` + +Place it with other authenticated routes. + +### 3. Add bookmark buttons to course cards + +In `AllCourses.jsx`: + +```jsx +import BookmarkButton from "../bookmarks/BookmarkButton"; +``` + +Inside the course-card map: + +```jsx + +``` + +### 4. Add a bookmark button to the course page + +In `CourseContent.jsx`: + +```jsx +import BookmarkButton from "../../bookmarks/BookmarkButton"; +``` + +Near the course heading: + +```jsx + +``` + +### 5. Add navigation count + +In the authenticated navbar: + +```jsx +import SavedCoursesNavLink from "../bookmarks/SavedCoursesNavLink"; +``` + +Render: + +```jsx + +``` + +Adjust the relative import based on the navbar location. + +## Optimistic updates + +`BookmarksContext` updates the UI before the network request finishes. If the +request fails, it restores the previous state and the button displays an error. + +A browser custom event keeps course cards, detail pages, navigation count, and +the Saved Courses page synchronized without duplicate state implementations. + +## Manual testing + +1. Log out and click Save. Confirm redirect to login. +2. Log in as a student and save a course card. +3. Confirm the navigation count increases. +4. Refresh and confirm the course remains saved. +5. Open the course and confirm its button is still active. +6. Remove it from the detail page and confirm the card updates. +7. Save several free and paid courses. +8. Test category, access, availability, search, and sorting. +9. Delete a saved course as teacher/admin and confirm the stale item is marked unavailable. +10. Simulate a failed request and confirm optimistic rollback. +11. Test keyboard activation and visible focus. +12. Test mobile and desktop layouts. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6a9cd5b..5a91cf6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,11 @@ import Dashboard from "./components/common/Dashboard"; import CourseContent from "./components/user/student/CourseContent"; import SiteFooter from "./components/common/SiteFooter"; import LegalPlaceholder from "./components/common/LegalPlaceholder"; +import { + BookmarksProvider, +} from "./context/BookmarksContext"; + +import SavedCourses from "./components/bookmarks/SavedCourses"; export const UserContext = createContext(); @@ -36,6 +41,7 @@ function App() { return (
+
@@ -43,6 +49,10 @@ function App() { } /> } /> } +/> + } /> @@ -66,6 +76,7 @@ function App() {
+
); diff --git a/frontend/src/components/bookmarks/BookmarkButton.jsx b/frontend/src/components/bookmarks/BookmarkButton.jsx new file mode 100644 index 0000000..63a4f17 --- /dev/null +++ b/frontend/src/components/bookmarks/BookmarkButton.jsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useBookmarks } from "../../context/BookmarksContext"; +import "./Bookmarks.css"; + +const BookmarkButton = ({ + courseId, + compact = false, + className = "", + onChange, +}) => { + const navigate = useNavigate(); + const { isBookmarked, toggleBookmark, isAuthenticated } = + useBookmarks(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + const bookmarked = isBookmarked(courseId); + + const handleClick = async (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (!isAuthenticated) { + navigate("/login"); + return; + } + + setSaving(true); + setError(""); + + try { + const nextValue = await toggleBookmark(courseId); + onChange?.(nextValue); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Saved-course status could not be updated.", + ); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + {error ? ( + + {error} + + ) : null} +
+ ); +}; + +export default BookmarkButton; diff --git a/frontend/src/components/bookmarks/Bookmarks.css b/frontend/src/components/bookmarks/Bookmarks.css new file mode 100644 index 0000000..d25936c --- /dev/null +++ b/frontend/src/components/bookmarks/Bookmarks.css @@ -0,0 +1,443 @@ +.bookmark-control { + position: relative; +} + +.bookmark-button { + min-height: 40px; + padding: 0 15px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid rgba(23, 32, 29, 0.2); + border-radius: 999px; + color: #17201d; + background: #fffdf8; + font-weight: 800; + cursor: pointer; +} + +.bookmark-button > span:first-child { + font-size: 1.15rem; + line-height: 1; +} + +.bookmark-button.is-bookmarked { + color: #745313; + border-color: rgba(224, 167, 47, 0.45); + background: #fff6d9; +} + +.bookmark-button.is-compact { + width: 42px; + min-height: 42px; + padding: 0; +} + +.bookmark-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.bookmark-inline-error { + width: 210px; + margin-top: 6px; + padding: 7px 9px; + position: absolute; + top: 100%; + right: 0; + z-index: 10; + border-radius: 8px; + color: #8d2929; + background: #fff0ee; + font-size: 0.72rem; +} + +.saved-courses-page { + --saved-ink: #17201d; + --saved-muted: #68716d; + --saved-paper: #f5f1e8; + --saved-surface: #fffdf8; + --saved-line: rgba(23, 32, 29, 0.15); + --saved-blue: #315bd6; + --saved-gold: #e0a72f; + + min-height: 100vh; + padding: clamp(22px, 5vw, 64px); + color: var(--saved-ink); + background: + radial-gradient( + circle at 96% 0%, + rgba(49, 91, 214, 0.12), + transparent 31rem + ), + var(--saved-paper); +} + +.saved-courses-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.saved-courses-header p { + margin: 0 0 8px; + color: var(--saved-blue); + font-size: 0.7rem; + font-weight: 900; + letter-spacing: 0.16em; +} + +.saved-courses-header h1 { + margin: 0; + font-size: clamp(2.5rem, 7vw, 5.2rem); + letter-spacing: -0.07em; + line-height: 0.95; +} + +.saved-courses-header span { + margin-top: 14px; + display: block; + color: var(--saved-muted); +} + +.clear-bookmarks-button, +.saved-filter-actions button, +.saved-course-error button, +.saved-course-pagination button { + min-height: 42px; + padding: 0 16px; + border: 1px solid var(--saved-line); + border-radius: 999px; + color: var(--saved-ink); + background: var(--saved-surface); + font-weight: 800; + cursor: pointer; +} + +.clear-bookmarks-button:disabled, +.saved-course-pagination button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.saved-course-filters { + margin: 34px 0 22px; + padding: 12px; + display: grid; + grid-template-columns: + minmax(220px, 1.5fr) + repeat(4, minmax(125px, 0.8fr)); + gap: 10px; + border: 1px solid var(--saved-line); + border-radius: 20px; + background: var(--saved-surface); +} + +.saved-course-filters label { + min-width: 0; + padding: 9px 12px; + display: flex; + flex-direction: column; + border-radius: 12px; + background: var(--saved-paper); +} + +.saved-course-filters label > span { + margin-bottom: 4px; + color: var(--saved-muted); + font-size: 0.63rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.saved-course-filters input, +.saved-course-filters select { + width: 100%; + min-height: 30px; + border: 0; + outline: 0; + color: var(--saved-ink); + background: transparent; +} + +.saved-filter-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.saved-filter-actions button:first-child { + color: white; + border-color: var(--saved-ink); + background: var(--saved-ink); +} + +.saved-result-summary { + margin: 0 2px 12px; + display: flex; + justify-content: space-between; + color: var(--saved-muted); + font-size: 0.82rem; +} + +.saved-course-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 15px; +} + +.saved-course-card { + min-height: 340px; + padding: 22px; + display: flex; + flex-direction: column; + border: 1px solid var(--saved-line); + border-radius: 21px; + background: var(--saved-surface); +} + +.saved-course-card.is-unavailable { + opacity: 0.76; + background: #eeeae1; +} + +.saved-course-card-top { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.saved-course-card-top > span:first-child, +.unavailable-label { + color: var(--saved-blue); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.saved-course-card h2 { + margin: 22px 0 10px; + font-size: 1.45rem; + letter-spacing: -0.035em; +} + +.saved-course-card > p { + margin: 0; + color: var(--saved-muted); + line-height: 1.6; +} + +.saved-course-card dl { + margin: auto 0 18px; + padding-top: 22px; +} + +.saved-course-card dl div { + padding: 7px 0; + display: flex; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--saved-line); +} + +.saved-course-card dt { + color: var(--saved-muted); + font-size: 0.7rem; + text-transform: uppercase; +} + +.saved-course-card dd { + margin: 0; + text-align: right; + font-size: 0.8rem; + font-weight: 700; +} + +.saved-course-open, +.saved-course-empty a { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + color: white; + background: var(--saved-ink); + font-weight: 800; + text-decoration: none; +} + +.saved-course-unavailable-copy { + margin-top: 14px !important; + padding: 11px; + border-radius: 10px; + background: rgba(23, 32, 29, 0.07); + font-size: 0.78rem; +} + +.saved-course-empty, +.saved-course-error { + padding: 58px 22px; + text-align: center; + border: 1px dashed var(--saved-line); + border-radius: 20px; + background: rgba(255, 253, 248, 0.75); +} + +.saved-course-empty > span { + font-size: 3rem; + color: var(--saved-gold); +} + +.saved-course-empty h2 { + margin: 8px 0; +} + +.saved-course-empty p, +.saved-course-error p { + margin: 0 0 20px; + color: var(--saved-muted); +} + +.saved-course-empty a { + padding: 0 18px; +} + +.saved-course-error { + color: #8d2929; + border-color: rgba(190, 48, 48, 0.35); + background: #fff0ee; +} + +.saved-course-skeleton { + min-height: 340px; + border: 1px solid var(--saved-line); + border-radius: 21px; + background: + linear-gradient( + 90deg, + transparent, + rgba(49, 91, 214, 0.07), + transparent + ), + var(--saved-surface); + background-size: 240% 100%; + animation: savedShimmer 1.5s linear infinite; +} + +.saved-course-pagination { + margin-top: 20px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.saved-courses-nav-link { + display: inline-flex; + align-items: center; + gap: 7px; + text-decoration: none; +} + +.saved-courses-nav-link strong { + min-width: 22px; + height: 22px; + padding: 0 6px; + display: inline-grid; + place-items: center; + border-radius: 999px; + color: white; + background: #315bd6; + font-size: 0.7rem; +} + +.sr-only { + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + position: absolute; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes savedShimmer { + to { + background-position: -240% 0; + } +} + +@media (max-width: 1050px) { + .saved-course-filters { + grid-template-columns: repeat(3, 1fr); + } + + .saved-course-search { + grid-column: span 2; + } + + .saved-course-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .saved-courses-header { + align-items: flex-start; + flex-direction: column; + } + + .saved-course-filters { + grid-template-columns: 1fr 1fr; + } + + .saved-course-search { + grid-column: 1 / -1; + } + + .saved-course-grid { + grid-template-columns: 1fr; + } + + .saved-filter-actions { + align-items: stretch; + flex-direction: column; + } + + .saved-result-summary { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + + .saved-course-pagination { + justify-content: space-between; + } +} + +@media (max-width: 430px) { + .saved-courses-page { + padding: 20px 14px 36px; + } + + .saved-course-filters { + grid-template-columns: 1fr; + } + + .saved-course-search { + grid-column: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .saved-course-skeleton { + animation: none; + } +} diff --git a/frontend/src/components/bookmarks/SavedCourses.jsx b/frontend/src/components/bookmarks/SavedCourses.jsx new file mode 100644 index 0000000..9a992e4 --- /dev/null +++ b/frontend/src/components/bookmarks/SavedCourses.jsx @@ -0,0 +1,414 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Link, useNavigate } from "react-router-dom"; +import axiosInstance from "../common/AxiosInstance"; +import { useBookmarks } from "../../context/BookmarksContext"; +import BookmarkButton from "./BookmarkButton"; +import "./Bookmarks.css"; + +const initialFilters = { + search: "", + category: "", + access: "", + availability: "", + sort: "recent", +}; + +const authConfig = () => ({ + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, +}); + +const SavedCourses = () => { + const navigate = useNavigate(); + const { + bookmarkCount, + clearAllBookmarks, + refreshBookmarks, + } = useBookmarks(); + + const [draftFilters, setDraftFilters] = useState(initialFilters); + const [filters, setFilters] = useState(initialFilters); + const [items, setItems] = useState([]); + const [categories, setCategories] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + totalPages: 1, + totalItems: 0, + hasPreviousPage: false, + hasNextPage: false, + }); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [clearing, setClearing] = useState(false); + const [error, setError] = useState(""); + + const queryString = useMemo(() => { + const params = new URLSearchParams({ + page: String(page), + limit: "12", + sort: filters.sort, + }); + + Object.entries(filters).forEach(([key, value]) => { + if (value && key !== "sort") params.set(key, value); + }); + + return params.toString(); + }, [filters, page]); + + const loadSavedCourses = useCallback(async () => { + setLoading(true); + setError(""); + + try { + const response = await axiosInstance.get( + `/api/bookmarks?${queryString}`, + authConfig(), + ); + + setItems(response.data.data || []); + setCategories(response.data.categories || []); + setPagination(response.data.pagination); + } catch (requestError) { + if (requestError.response?.status === 401) { + navigate("/login"); + return; + } + + setError( + requestError.response?.data?.message || + "Saved courses could not be loaded.", + ); + } finally { + setLoading(false); + } + }, [navigate, queryString]); + + useEffect(() => { + loadSavedCourses(); + }, [loadSavedCourses]); + + useEffect(() => { + const refresh = () => loadSavedCourses(); + + window.addEventListener("learnhub:bookmark-change", refresh); + window.addEventListener("learnhub:bookmarks-cleared", refresh); + + return () => { + window.removeEventListener( + "learnhub:bookmark-change", + refresh, + ); + window.removeEventListener( + "learnhub:bookmarks-cleared", + refresh, + ); + }; + }, [loadSavedCourses]); + + const applyFilters = (event) => { + event.preventDefault(); + setPage(1); + setFilters(draftFilters); + }; + + const clearFilters = () => { + setDraftFilters(initialFilters); + setFilters(initialFilters); + setPage(1); + }; + + const handleClearAll = async () => { + if (!bookmarkCount) return; + + const confirmed = window.confirm( + "Remove every course from your saved list?", + ); + + if (!confirmed) return; + + setClearing(true); + setError(""); + + try { + await clearAllBookmarks(); + await refreshBookmarks(); + await loadSavedCourses(); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Saved courses could not be cleared.", + ); + } finally { + setClearing(false); + } + }; + + return ( +
+
+
+

SAVED FOR LATER

+

My course wishlist

+ + {bookmarkCount}{" "} + {bookmarkCount === 1 ? "saved course" : "saved courses"} + +
+ + +
+ +
+ + + + + + + + + + +
+ + +
+
+ + {error ? ( +
+ Unable to show saved courses +

{error}

+ +
+ ) : null} + + {loading ? ( +
+ Loading saved courses + {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : items.length === 0 && !error ? ( +
+ +

No saved courses found

+

+ Save courses from the catalog and they will appear here. +

+ Browse courses +
+ ) : !error ? ( + <> +
+ + {pagination.totalItems}{" "} + {pagination.totalItems === 1 ? "result" : "results"} + + + Page {pagination.page} of {pagination.totalPages} + +
+ +
+ {items.map(({ bookmarkId, savedAt, course }) => ( +
+
+ {course.category} + {course.id ? ( + + ) : ( + + Unavailable + + )} +
+ +

{course.title}

+

{course.description}

+ +
+
+
Educator
+
{course.educator}
+
+
+
Access
+
+ {course.accessType === "free" + ? "Free" + : course.price} +
+
+
+
Saved
+
+ {new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(new Date(savedAt))} +
+
+
+ + {course.id ? ( + + Open course → + + ) : ( +

+ This course was removed from the catalog. You can + safely remove it from your wishlist. +

+ )} +
+ ))} +
+ + {pagination.totalPages > 1 ? ( + + ) : null} + + ) : null} +
+ ); +}; + +export default SavedCourses; diff --git a/frontend/src/components/bookmarks/SavedCoursesNavLink.jsx b/frontend/src/components/bookmarks/SavedCoursesNavLink.jsx new file mode 100644 index 0000000..b3b4ed1 --- /dev/null +++ b/frontend/src/components/bookmarks/SavedCoursesNavLink.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { useBookmarks } from "../../context/BookmarksContext"; + +const SavedCoursesNavLink = ({ className = "" }) => { + const { bookmarkCount } = useBookmarks(); + + return ( + + + Saved + {bookmarkCount} + + ); +}; + +export default SavedCoursesNavLink; diff --git a/frontend/src/components/common/AllCourses.jsx b/frontend/src/components/common/AllCourses.jsx index ee3e757..1e8f288 100644 --- a/frontend/src/components/common/AllCourses.jsx +++ b/frontend/src/components/common/AllCourses.jsx @@ -4,6 +4,7 @@ import { MDBCol, MDBInput, MDBRow } from "mdb-react-ui-kit"; import { Link, useNavigate } from "react-router-dom"; import { UserContext } from "../../App"; import axiosInstance from "./AxiosInstance"; +import BookmarkButton from "../bookmarks/BookmarkButton"; const paletteByCategory = [ ["#f2c14e", "#e56b6f"], @@ -235,6 +236,10 @@ const AllCourses = () => { {course.C_categories || "General"} + {levelForCourse(course, index)} diff --git a/frontend/src/components/common/NavBar.jsx b/frontend/src/components/common/NavBar.jsx index 2757127..0e8e58c 100644 --- a/frontend/src/components/common/NavBar.jsx +++ b/frontend/src/components/common/NavBar.jsx @@ -2,6 +2,7 @@ import React, { useContext, useState, useEffect, useRef } from 'react' import { Navbar, Nav, Button, Container } from 'react-bootstrap'; import { UserContext } from '../../App'; import { NavLink } from 'react-router-dom'; +import SavedCoursesNavLink from "../bookmarks/SavedCoursesNavLink"; const NavBar = ({ setSelectedComponent }) => { @@ -123,6 +124,7 @@ const NavBar = ({ setSelectedComponent }) => { Log Out + diff --git a/frontend/src/components/user/student/CourseContent.jsx b/frontend/src/components/user/student/CourseContent.jsx index d79442c..edcd2b2 100644 --- a/frontend/src/components/user/student/CourseContent.jsx +++ b/frontend/src/components/user/student/CourseContent.jsx @@ -8,6 +8,7 @@ import NavBar from '../../common/NavBar'; import html2canvas from "html2canvas"; import { jsPDF } from "jspdf"; import { Button } from '@mui/material'; +import BookmarkButton from "../../bookmarks/BookmarkButton"; const CourseContent = () => { const user = useContext(UserContext) @@ -97,7 +98,7 @@ const CourseContent = () => { <>

Welcome to the course: {courseTitle}

- +
diff --git a/frontend/src/context/BookmarksContext.jsx b/frontend/src/context/BookmarksContext.jsx new file mode 100644 index 0000000..2abd7a8 --- /dev/null +++ b/frontend/src/context/BookmarksContext.jsx @@ -0,0 +1,221 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import axiosInstance from "../components/common/AxiosInstance"; + +const BookmarksContext = createContext(null); + +const authConfig = () => ({ + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, +}); + +export const BookmarksProvider = ({ children }) => { + const [bookmarkIds, setBookmarkIds] = useState(() => new Set()); + const [loading, setLoading] = useState(false); + const [ready, setReady] = useState(false); + const requestVersion = useRef(0); + + const isAuthenticated = Boolean(localStorage.getItem("token")); + + const refreshBookmarks = useCallback(async () => { + if (!isAuthenticated) { + setBookmarkIds(new Set()); + setReady(true); + return; + } + + const version = ++requestVersion.current; + setLoading(true); + + try { + const response = await axiosInstance.get( + "/api/bookmarks?limit=50", + authConfig(), + ); + + if (version !== requestVersion.current) return; + + const ids = (response.data.data || []) + .map((item) => item.course?.id) + .filter(Boolean); + + setBookmarkIds(new Set(ids)); + } catch (error) { + if (version === requestVersion.current) { + console.error("Unable to load saved courses:", error); + } + } finally { + if (version === requestVersion.current) { + setLoading(false); + setReady(true); + } + } + }, [isAuthenticated]); + + useEffect(() => { + refreshBookmarks(); + }, [refreshBookmarks]); + + useEffect(() => { + const sync = (event) => { + const detail = event.detail || {}; + + if (!detail.courseId) return; + + setBookmarkIds((current) => { + const next = new Set(current); + + if (detail.bookmarked) next.add(detail.courseId); + else next.delete(detail.courseId); + + return next; + }); + }; + + window.addEventListener("learnhub:bookmark-change", sync); + return () => + window.removeEventListener("learnhub:bookmark-change", sync); + }, []); + + const setBookmarkLocally = useCallback((courseId, bookmarked) => { + setBookmarkIds((current) => { + const next = new Set(current); + + if (bookmarked) next.add(courseId); + else next.delete(courseId); + + return next; + }); + + window.dispatchEvent( + new CustomEvent("learnhub:bookmark-change", { + detail: { courseId, bookmarked }, + }), + ); + }, []); + + const toggleBookmark = useCallback( + async (courseId) => { + if (!isAuthenticated) { + const error = new Error("Sign in to save courses."); + error.code = "AUTH_REQUIRED"; + throw error; + } + + const wasBookmarked = bookmarkIds.has(courseId); + const nextValue = !wasBookmarked; + + setBookmarkLocally(courseId, nextValue); + + try { + if (nextValue) { + await axiosInstance.post( + `/api/bookmarks/${courseId}`, + {}, + authConfig(), + ); + } else { + await axiosInstance.delete( + `/api/bookmarks/${courseId}`, + authConfig(), + ); + } + + return nextValue; + } catch (error) { + setBookmarkLocally(courseId, wasBookmarked); + throw error; + } + }, + [ + bookmarkIds, + isAuthenticated, + setBookmarkLocally, + ], + ); + + const removeBookmark = useCallback( + async (courseId) => { + if (!bookmarkIds.has(courseId)) return; + + setBookmarkLocally(courseId, false); + + try { + await axiosInstance.delete( + `/api/bookmarks/${courseId}`, + authConfig(), + ); + } catch (error) { + setBookmarkLocally(courseId, true); + throw error; + } + }, + [bookmarkIds, setBookmarkLocally], + ); + + const clearAllBookmarks = useCallback(async () => { + const previous = new Set(bookmarkIds); + setBookmarkIds(new Set()); + + try { + await axiosInstance.delete("/api/bookmarks", authConfig()); + window.dispatchEvent( + new CustomEvent("learnhub:bookmarks-cleared"), + ); + } catch (error) { + setBookmarkIds(previous); + throw error; + } + }, [bookmarkIds]); + + const value = useMemo( + () => ({ + bookmarkIds, + bookmarkCount: bookmarkIds.size, + isBookmarked: (courseId) => bookmarkIds.has(courseId), + toggleBookmark, + removeBookmark, + clearAllBookmarks, + refreshBookmarks, + loading, + ready, + isAuthenticated, + }), + [ + bookmarkIds, + toggleBookmark, + removeBookmark, + clearAllBookmarks, + refreshBookmarks, + loading, + ready, + isAuthenticated, + ], + ); + + return ( + + {children} + + ); +}; + +export const useBookmarks = () => { + const context = useContext(BookmarksContext); + + if (!context) { + throw new Error( + "useBookmarks must be used inside BookmarksProvider.", + ); + } + + return context; +};