Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions app/dashboard/saved/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"use client";

import React, { useEffect, useState } from "react";
import Link from "next/link";
import CourseCard from "@/components/molecules/dashboard/cards/courseCard";
import LibraryBookCard from "@/components/molecules/dashboard/cards/libraryCard";
import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton";
import LibraryBookSkeleton from "@/components/atoms/skeletons/LibraryBookSkeleton";
import NetworkErrorComp from "@/components/molecules/errors/NetworkError";
import Button from "@/components/atoms/form/Button";
import { getBookmarkedCourses } from "@/lib/actions/courses/bookmark-course";
import { getBookmarkedBooks } from "@/lib/actions/library/bookmark-book";
import { Bookmark, LaptopMinimal, Book } from "lucide-react";

export default function SavedPage() {
const [activeTab, setActiveTab] = useState("courses"); // "courses" | "books"
const [courses, setCourses] = useState([]);
const [books, setBooks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);

const fetchSavedItems = async () => {
setLoading(true);
setError(false);
try {
const [coursesRes, booksRes] = await Promise.allSettled([
getBookmarkedCourses(),
getBookmarkedBooks(),
]);

if (coursesRes.status === "fulfilled") {
const courseData = coursesRes.value;
setCourses(courseData?.bookmarks || (Array.isArray(courseData) ? courseData : []));
}

if (booksRes.status === "fulfilled") {
const bookData = booksRes.value;
setBooks(bookData?.bookmarks || (Array.isArray(bookData) ? bookData : []));
}

if (coursesRes.status === "rejected" && booksRes.status === "rejected") {
setError(true);
}
} catch (_err) {
setError(true);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchSavedItems();
}, []);

const handleCourseBookmarkChange = (isBookmarked, courseId) => {
if (!isBookmarked) {
setCourses((prev) => prev.filter((c) => c._id !== courseId));
}
};

const handleBookBookmarkChange = (isBookmarked, bookId) => {
if (!isBookmarked) {
setBooks((prev) => prev.filter((b) => b._id !== bookId));
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (error) {
return (
<NetworkErrorComp
errMsg="Failed to load saved items. Please try again."
reset={() => fetchSavedItems()}
/>
);
}

return (
<div className="bg-muted min-h-full w-full p-5">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Bookmark className="w-6 h-6 text-accent fill-accent" />
My Saved Hub
</h2>
<p className="text-muted-foreground text-sm mt-1">
Access all your bookmarked courses and books in one place
</p>
</div>

{/* Tabs Toggle */}
<div className="flex gap-2 bg-background/60 p-1.5 rounded-full border shadow-sm">
<button
type="button"
onClick={() => setActiveTab("courses")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold rounded-full transition-all cursor-pointer ${
activeTab === "courses"
? "bg-accent text-white shadow"
: "text-muted-foreground hover:text-foreground"
}`}
>
<LaptopMinimal className="w-4 h-4" />
Courses ({courses.length})
</button>

<button
type="button"
onClick={() => setActiveTab("books")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold rounded-full transition-all cursor-pointer ${
activeTab === "books"
? "bg-accent text-white shadow"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Book className="w-4 h-4" />
Books ({books.length})
</button>
</div>
</div>

{/* Content Section */}
<div className="mt-6">
{loading ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, idx) =>
activeTab === "courses" ? (
<CourseCardSkeleton key={`skeleton-course-${idx}`} />
) : (
<LibraryBookSkeleton key={`skeleton-book-${idx}`} />
)
)}
</div>
) : activeTab === "courses" ? (
courses.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-20 bg-background/40 rounded-2xl border border-dashed p-8">
<LaptopMinimal className="w-12 h-12 text-muted-foreground/50 mb-3" />
<h3 className="text-lg font-semibold mb-1">No saved courses yet</h3>
<p className="text-muted-foreground text-sm max-w-md mb-6">
Explore our catalog and bookmark courses you are interested in to view them here later.
</p>
<Button to="/dashboard/courses" round className="bg-accent text-white">
Browse Courses
</Button>
</div>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<CourseCard
key={course._id}
course={course}
initialIsBookmarked={true}
onBookmarkChange={(isBookmarked) =>
handleCourseBookmarkChange(isBookmarked, course._id)
}
/>
))}
</div>
)
) : books.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-20 bg-background/40 rounded-2xl border border-dashed p-8">
<Book className="w-12 h-12 text-muted-foreground/50 mb-3" />
<h3 className="text-lg font-semibold mb-1">No saved books yet</h3>
<p className="text-muted-foreground text-sm max-w-md mb-6">
Browse our Islamic library and save books to build your personal reading list.
</p>
<Button to="/dashboard/library" round className="bg-accent text-white">
Browse Library
</Button>
</div>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3">
{books.map((book) => (
<LibraryBookCard
key={book._id}
book={book}
initialIsBookmarked={true}
onBookmarkChange={(isBookmarked) =>
handleBookBookmarkChange(isBookmarked, book._id)
}
/>
))}
</div>
)}
</div>
</div>
);
}
83 changes: 83 additions & 0 deletions components/atoms/BookmarkButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from "react";
import { Bookmark, BookmarkCheck, CirclePlus, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

/**
* Reusable BookmarkButton component with consistent filled/outlined and loading states.
*
* @param {Object} props
* @param {boolean} props.isBookmarked - Bookmark active state
* @param {boolean} [props.loading] - Loading state during API request
* @param {function} props.onClick - Click handler
* @param {string} [props.variant="course"] - "course" | "book" icon variant
* @param {string} [props.title] - Hover title
* @param {string} [props.ariaLabel] - Accessible label
* @param {string} [props.className] - Additional class names
*/
const BookmarkButton = ({
isBookmarked,
loading = false,
onClick,
variant = "course",
title,
ariaLabel,
className,
}) => {
const defaultTitle = isBookmarked ? "Remove bookmark" : "Add bookmark";
const buttonTitle = title || defaultTitle;
const buttonAriaLabel = ariaLabel || defaultTitle;

if (variant === "book") {
return (
<button
type="button"
onClick={onClick}
disabled={loading}
title={buttonTitle}
aria-label={buttonAriaLabel}
className={cn(
"flex items-center justify-center rounded-full p-1.5 transition-all",
"hover:bg-accent hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
loading ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
isBookmarked ? "bg-accent text-white" : "text-accent",
className
)}
>
{loading ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<CirclePlus
className="w-6 h-6"
strokeWidth={1.75}
fill={isBookmarked ? "currentColor" : "none"}
/>
)}
</button>
);
}

return (
<button
type="button"
onClick={onClick}
disabled={loading}
title={buttonTitle}
aria-label={buttonAriaLabel}
className={cn(
"transition-all hover:scale-110 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md p-1",
loading ? "opacity-60 cursor-not-allowed" : "",
className
)}
>
{loading ? (
<Loader2 className="w-6 h-6 text-accent animate-spin" />
) : isBookmarked ? (
<BookmarkCheck className="w-6 h-6 text-accent fill-accent" />
) : (
<Bookmark className="w-6 h-6 text-accent hover:fill-accent/20" />
)}
</button>
);
};

export default BookmarkButton;
24 changes: 10 additions & 14 deletions components/molecules/dashboard/cards/courseCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Button from "@/components/atoms/form/Button";
import Link from "next/link";
import { Ellipsis, Bookmark, BookmarkCheck } from "lucide-react";
import { Ellipsis } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import Image from "next/image";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useBookmark } from "@/hooks/useBookmark";
import BookmarkButton from "@/components/atoms/BookmarkButton";

const CourseCard = ({ course, onBookmarkChange }) => {
const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => {
const { user } = useAuth();
const { isBookmarked, loading, toggle } = useBookmark(
course._id,
onBookmarkChange
onBookmarkChange,
initialIsBookmarked
);

const handleBookmark = async (e) => {
Expand Down Expand Up @@ -81,18 +83,12 @@ const CourseCard = ({ course, onBookmarkChange }) => {
<div className="bg-gradient-to-r from-highlight to-accent text-white text-xs font-bold px-3 py-1 rounded-full shadow">
{course.price ? `$${course.price}` : "Free"}
</div>
<button
<BookmarkButton
isBookmarked={isBookmarked}
loading={loading}
onClick={handleBookmark}
disabled={loading}
className="transition-all hover:scale-110 cursor-pointer"
title={isBookmarked ? "Remove bookmark" : "Add bookmark"}
>
{isBookmarked ? (
<BookmarkCheck className="w-6 h-6 text-accent fill-accent" />
) : (
<Bookmark className="w-6 h-6 text-accent hover:fill-accent/20" />
)}
</button>
variant="course"
/>
</div>
</div>
</CardContent>
Expand Down
30 changes: 9 additions & 21 deletions components/molecules/dashboard/cards/libraryCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Star } from "lucide-react"; // optional: use a custom star icon or emoj
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import Link from "next/link";
import { getAverageRating } from "@/hooks/getAverageRating";
import { CirclePlus } from "lucide-react";
import { cn } from "@/lib/utils";
import useBookBookmark from "@/hooks/useBookBookmark";
import BookmarkButton from "@/components/atoms/BookmarkButton";

const LibraryBookCard = ({ book, onBookmarkChange }) => {
const LibraryBookCard = ({ book, onBookmarkChange, initialIsBookmarked }) => {
const { isBookmarked, loading, toggle } = useBookBookmark(
book._id,
onBookmarkChange
onBookmarkChange,
initialIsBookmarked
);

const handleBookmark = async (event) => {
Expand Down Expand Up @@ -66,24 +66,12 @@ const LibraryBookCard = ({ book, onBookmarkChange }) => {
</div>
</Link>
{/* Bookmark button */}
<button
<BookmarkButton
isBookmarked={isBookmarked}
loading={loading}
onClick={handleBookmark}
disabled={loading}
className={cn(
"flex items-center justify-center rounded-full p-1 transition-all",
"hover:bg-accent hover:text-white",
loading ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
isBookmarked ? "bg-accent text-white" : "text-accent"
)}
title={isBookmarked ? "Remove bookmark" : "Add bookmark"}
aria-label={isBookmarked ? "Remove bookmark" : "Add bookmark"}
>
<CirclePlus
className="w-6 h-6"
strokeWidth={1.75}
fill={isBookmarked ? "currentColor" : "none"}
/>
</button>
variant="book"
/>
</div>
{/* Reads & Rating */}
<div className="flex justify-between items-center text-xs">
Expand Down
8 changes: 7 additions & 1 deletion components/molecules/dashboard/nav-routers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
Play,
LaptopMinimal,
HeartHandshake,
DollarSign
DollarSign,
Bookmark,
} from "lucide-react";
import {
SidebarGroup,
Expand All @@ -36,6 +37,11 @@ const links = [
link: "/dashboard/library",
icon: Book,
},
{
name: "Saved",
link: "/dashboard/saved",
icon: Bookmark,
},
{
name: "Spaces",
link: "/dashboard/spaces",
Expand Down
Loading
Loading